evidence_record 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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # Internal: one exchange with a TSA for a hash value (docs/design.md 3.3,
5
+ # 6). Makes the request and takes the time-stamp token out of the response
6
+ # after checking it. Used by Generator and the renewals.
7
+ class TimestampExchange
8
+ GRANTED = 0
9
+ GRANTED_WITH_MODS = 1
10
+
11
+ def initialize(algorithm, imprint)
12
+ @algorithm = algorithm
13
+ @imprint = Digest.wrap(imprint)
14
+ end
15
+
16
+ # An OpenSSL::Timestamp::Request for the hash value. The nonce and policy
17
+ # given here are checked against the response in #token.
18
+ def request(nonce: nil, cert_requested: true, policy_id: nil)
19
+ raise ArgumentError, "nonce must be an Integer, got #{nonce.class}" unless nonce.nil? || nonce.is_a?(Integer)
20
+ unless policy_id.nil? || policy_id.is_a?(String)
21
+ raise ArgumentError, "policy_id must be a String (OID), got #{policy_id.class}"
22
+ end
23
+
24
+ @nonce = nonce
25
+ @policy_id = policy_id
26
+ request = OpenSSL::Timestamp::Request.new
27
+ request.algorithm = @algorithm.name.to_s.upcase
28
+ request.message_imprint = @imprint.to_binary
29
+ request.nonce = nonce if nonce
30
+ request.policy_id = policy_id if policy_id
31
+ request.cert_requested = cert_requested ? true : false
32
+ request
33
+ end
34
+
35
+ # The TimeStampToken in response: a String (the TimeStampResp as
36
+ # returned by the TSA) or an OpenSSL::Timestamp::Response. Lengths encoded
37
+ # as BER are rewritten as DER; anything else that is not DER is rejected
38
+ # (docs/design.md 6). Raises TimestampResponseError when the response
39
+ # cannot be used.
40
+ #
41
+ # The signature is not checked here; verify the resulting record.
42
+ def token(response)
43
+ token = extract(response_bytes(response))
44
+ check!(token)
45
+ token
46
+ end
47
+
48
+ private
49
+
50
+ def response_bytes(response)
51
+ case response
52
+ when String then response
53
+ when OpenSSL::Timestamp::Response then response.to_der
54
+ else
55
+ raise ArgumentError,
56
+ "response must be a String or an OpenSSL::Timestamp::Response, got #{response.class}"
57
+ end
58
+ end
59
+
60
+ # TimeStampResp ::= SEQUENCE { status PKIStatusInfo,
61
+ # timeStampToken TimeStampToken OPTIONAL }
62
+ # PKIStatusInfo ::= SEQUENCE { status PKIStatus,
63
+ # statusString PKIFreeText OPTIONAL, failInfo PKIFailureInfo OPTIONAL }
64
+ def extract(bytes)
65
+ node = DER.parse(DER.normalize_lengths(bytes, "time-stamp response"), "time-stamp response")
66
+ status_info, token = DER.children_of(node, "time-stamp response", size: 1..2)
67
+ status, *details = DER.children_of(status_info, "time-stamp response status", size: 1..3)
68
+ code = DER.integer(status, "time-stamp response status")
69
+ unless [GRANTED, GRANTED_WITH_MODS].include?(code)
70
+ raise TimestampResponseError, "the TSA rejected the request (status #{code}#{status_text(details)})"
71
+ end
72
+ raise TimestampResponseError, "the response has no time-stamp token" unless token
73
+
74
+ TimeStampToken.from_node(token, "time-stamp token")
75
+ rescue ParseError => e
76
+ raise TimestampResponseError, "invalid time-stamp response: #{e.message}"
77
+ end
78
+
79
+ # PKIFreeText ::= SEQUENCE SIZE (1..MAX) OF UTF8String
80
+ def status_text(details)
81
+ text = details.find { _1.universal?(DER::SEQUENCE) }
82
+ return "" unless text
83
+
84
+ strings = text.children.select { _1.universal?(12) }.map { _1.content.dup.force_encoding(Encoding::UTF_8).scrub }
85
+ strings.empty? ? "" : ": #{strings.join('; ')}"
86
+ end
87
+
88
+ def check!(token)
89
+ unless token.hash_algorithm_oid == @algorithm.oid
90
+ raise TimestampResponseError,
91
+ "the time-stamp uses hash algorithm #{token.hash_algorithm_oid}, but #{@algorithm} was requested"
92
+ end
93
+ unless token.message_imprint == @imprint
94
+ raise TimestampResponseError, "the message imprint of the time-stamp does not match the request"
95
+ end
96
+ if @nonce && token.nonce != @nonce
97
+ raise TimestampResponseError, "the nonce of the time-stamp does not match the request"
98
+ end
99
+ return unless @policy_id && token.policy_id != @policy_id
100
+
101
+ raise TimestampResponseError, "the policy of the time-stamp does not match the request"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # A time-stamp renewal of a Record (RFC 4998 §5.2, docs/design.md 3.4):
5
+ # a new ArchiveTimeStamp, added to the last chain, that covers the
6
+ # time-stamps of that chain. Done before the time-stamps become invalid
7
+ # (for example, before the TSA certificate expires).
8
+ #
9
+ # renewal = record.timestamp_renewal
10
+ # req = renewal.timestamp_request(nonce: SecureRandom.random_number(2**64))
11
+ # res = post_to_tsa(req.to_der) # the TSA response, as a String
12
+ # record2 = renewal.build(res) # => a new Record
13
+ #
14
+ # The new time-stamp covers all the time-stamps of the last chain, as one
15
+ # data object group, as Bouncy Castle does. Its hash algorithm is the one
16
+ # of the last chain; when that algorithm weakens, use a hash-tree renewal
17
+ # (HashTreeRenewal) instead.
18
+ #
19
+ # The data are not needed and the record is not verified. Records built
20
+ # from the same hash tree and renewed in the same way have the same
21
+ # time-stamps, so one TSA response can renew all of them: build a renewal
22
+ # for each record and pass the same response to build.
23
+ #
24
+ # See Renewal for #timestamp_request and #build.
25
+ class TimestampRenewal
26
+ include Renewal
27
+
28
+ # Internal: use Record#timestamp_renewal.
29
+ def initialize(record)
30
+ chain = record.chains.last
31
+ if chain.nil? || chain.archive_timestamps.empty?
32
+ raise MalformedRecordError, "the record has no ArchiveTimeStamp to renew"
33
+ end
34
+
35
+ oids = chain.archive_timestamps.map(&:digest_algorithm_oid).uniq
36
+ raise MalformedRecordError, "hash algorithms are mixed within the last chain" unless oids.size == 1
37
+
38
+ @chain = chain
39
+ algorithm = DigestAlgorithm.from_oid(oids[0])
40
+ prepare(record, algorithm, chain.archive_timestamps.map { algorithm.digest(_1.timestamp.to_der) })
41
+ end
42
+
43
+ private
44
+
45
+ def renewed_chains(archive_timestamp)
46
+ @record.chains[0...-1] + [Chain.build(@chain.archive_timestamps + [archive_timestamp])]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # Time-stamp validators: objects that check that a time-stamp token itself
5
+ # is valid (docs/design.md 5.3).
6
+ #
7
+ # A validator responds to call(token, at:), where token is a TimeStampToken
8
+ # and at is the Time at which the token must be valid. It returns a truthy
9
+ # value when the token is valid. When it is not, it raises
10
+ # EvidenceRecord::TimestampInvalidError or returns false or nil; both are
11
+ # treated as invalid, so a validator that forgets to return true fails
12
+ # closed. Other exceptions are not caught.
13
+ module TimestampValidator
14
+ # Checks the signature of the token and its certificate chain at the given
15
+ # time with OpenSSL, using a store of trusted certificates.
16
+ #
17
+ # store = OpenSSL::X509::Store.new
18
+ # store.add_cert(tsa_root)
19
+ # validator = EvidenceRecord::TimestampValidator::OpenSSL.new(store: store)
20
+ #
21
+ # What it checks: the signature, that the signing certificate is the one
22
+ # named in the token (ESS signing certificate), that it may be used for
23
+ # time-stamping, and that its chain to a trusted certificate is valid at
24
+ # the given time.
25
+ #
26
+ # What it does not check: revocation (CRL / OCSP) unless the store is set
27
+ # up for it, and whether the algorithms used are still considered secure.
28
+ # Give your own validator for those.
29
+ #
30
+ # The store's verification time is set on every call, so do not share the
31
+ # store with other code that relies on it.
32
+ class OpenSSL
33
+ def initialize(store:)
34
+ unless store.is_a?(::OpenSSL::X509::Store)
35
+ raise ArgumentError, "store must be an OpenSSL::X509::Store, got #{store.class}"
36
+ end
37
+
38
+ @store = store
39
+ end
40
+
41
+ def call(token, at:)
42
+ raise ArgumentError, "token must be an EvidenceRecord::TimeStampToken" unless token.is_a?(TimeStampToken)
43
+ raise ArgumentError, "at must be a Time, got #{at.class}" unless at.is_a?(Time)
44
+
45
+ @store.time = at
46
+ response_for(token).verify(request_for(token), @store)
47
+ true
48
+ rescue ::OpenSSL::Timestamp::TimestampError => e
49
+ raise TimestampInvalidError, "invalid time-stamp: #{e.message}"
50
+ end
51
+
52
+ private
53
+
54
+ # A request that the token must match. It is built from the token
55
+ # itself: the hash chain has already been checked against the imprint.
56
+ def request_for(token)
57
+ request = ::OpenSSL::Timestamp::Request.new
58
+ request.algorithm = token.hash_algorithm.name.to_s.upcase
59
+ request.message_imprint = token.message_imprint.to_binary
60
+ request.nonce = token.nonce if token.nonce
61
+ request
62
+ end
63
+
64
+ # A granted TimeStampResp holding the token without its crls field
65
+ # (docs/design.md 7.2).
66
+ def response_for(token)
67
+ status = DER.sequence([DER.encode_integer(0)])
68
+ ::OpenSSL::Timestamp::Response.new(DER.sequence([status, token.der_without_crls]))
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # Internal: verifies a Record against data (docs/design.md 5.1). Used by
5
+ # Record#verify!, #verify_hashes! and the renewals.
6
+ class Verifier
7
+ def initialize(record, data)
8
+ @record = record
9
+ @data = DataSource.wrap(data)
10
+ end
11
+
12
+ # Steps 1-3: the structure and the hash chain, without looking at the
13
+ # signatures or the times of the time-stamps.
14
+ def verify_hashes!
15
+ check_structure!
16
+ @record.chains.each_with_index { |chain, ci| verify_chain!(chain, ci) }
17
+ true
18
+ end
19
+
20
+ # All steps.
21
+ def verify!(timestamp_validator:, at:)
22
+ verify_hashes!
23
+ check_times!(at)
24
+ validate_timestamps!(timestamp_validator, at)
25
+ true
26
+ end
27
+
28
+ private
29
+
30
+ # [ArchiveTimeStamp, chain index, index in chain] in chronological order.
31
+ def entries
32
+ @record.chains.each_with_index.flat_map do |chain, ci|
33
+ chain.archive_timestamps.each_with_index.map { |ats, ai| [ats, ci, ai] }
34
+ end
35
+ end
36
+
37
+ # Step 1.
38
+ def check_structure!
39
+ raise MalformedRecordError, "the record has no ArchiveTimeStampChain" if @record.chains.empty?
40
+
41
+ @record.chains.each_with_index do |chain, ci|
42
+ if chain.archive_timestamps.empty?
43
+ raise MalformedRecordError.new("chain #{ci} has no ArchiveTimeStamp", chain_index: ci)
44
+ end
45
+
46
+ oid = chain.archive_timestamps[0].digest_algorithm_oid
47
+ chain.archive_timestamps.each_with_index { |ats, ai| check_archive_timestamp!(ats, oid, ci, ai) }
48
+ end
49
+ end
50
+
51
+ def check_archive_timestamp!(ats, chain_oid, ci, ai)
52
+ where = { chain_index: ci, ats_index: ai }
53
+ unless ats.digest_algorithm_oid == chain_oid
54
+ raise MalformedRecordError.new("#{at(ci, ai)}: hash algorithms are mixed within a chain", **where)
55
+ end
56
+ declared = ats.declared_digest_algorithm_oid
57
+ if declared && declared != ats.timestamp.hash_algorithm_oid
58
+ raise MalformedRecordError.new(
59
+ "#{at(ci, ai)}: digestAlgorithm differs from the hash algorithm of the time-stamp", **where
60
+ )
61
+ end
62
+
63
+ algorithm = ats.digest_algorithm # raises UnsupportedAlgorithmError
64
+ values = ats.reduced_hashtree&.partial_hashtrees&.flatten || []
65
+ return if values.all? { _1.bytesize == algorithm.digest_size }
66
+
67
+ raise MalformedRecordError.new("#{at(ci, ai)}: a hash in reducedHashtree is not a #{algorithm} hash", **where)
68
+ end
69
+
70
+ # Steps 2 and 3.
71
+ def verify_chain!(chain, ci)
72
+ algorithm = chain.archive_timestamps[0].digest_algorithm
73
+ chain.archive_timestamps.each_with_index do |ats, ai|
74
+ if ai.zero?
75
+ check_covered!(ats, algorithm, data_hash_candidates(algorithm, ci), "the data", ci, ai)
76
+ else
77
+ previous = chain.archive_timestamps[ai - 1].timestamp.to_der
78
+ check_covered!(ats, algorithm, [[algorithm.digest(previous)]], "the previous time-stamp", ci, ai)
79
+ end
80
+ end
81
+ end
82
+
83
+ # The hashes that the first partial hash tree of chain ci must contain,
84
+ # as alternatives. In the first chain they are the hashes of the data
85
+ # (each member of a data group). In later chains they are H(H(d) + ha),
86
+ # where ha is the hash of the older chains; both the fixed order and the
87
+ # sorted order of the concatenation are accepted (docs/design.md 3.5).
88
+ def data_hash_candidates(algorithm, ci)
89
+ hashes = @data.member_digests(algorithm)
90
+ return [hashes] if ci.zero?
91
+
92
+ ha = algorithm.digest(@record.archive_timestamp_sequence_der(ci))
93
+ [hashes.map { algorithm.digest(_1, ha) }, hashes.map { algorithm.digest(*[_1, ha].sort) }].uniq
94
+ end
95
+
96
+ def check_covered!(ats, algorithm, candidates, subject, ci, ai)
97
+ imprint = ats.timestamp.message_imprint
98
+ contained = false
99
+ candidates.each do |hashes|
100
+ # Without a reduced hash tree, the hashes themselves make the root.
101
+ tree = ats.reduced_hashtree || ReducedHashtree.new([hashes])
102
+ next unless hashes.all? { |h| tree.leaf_values.include?(Digest.new(h)) }
103
+ return if tree.root(algorithm) == imprint
104
+
105
+ contained = true
106
+ end
107
+
108
+ where = { chain_index: ci, ats_index: ai }
109
+ if contained
110
+ raise HashMismatchError.new("#{at(ci, ai)}: the root of the hash tree does not match the time-stamp", **where)
111
+ end
112
+
113
+ raise HashMismatchError.new("#{at(ci, ai)}: #{subject} is not covered by the reduced hash tree", **where)
114
+ end
115
+
116
+ # Step 4.
117
+ def check_times!(time)
118
+ previous = nil
119
+ entries.each do |ats, ci, ai|
120
+ gen_time = ats.timestamp.gen_time
121
+ if previous && gen_time < previous
122
+ raise MalformedRecordError.new("#{at(ci, ai)}: the time-stamp is older than the one before",
123
+ chain_index: ci, ats_index: ai)
124
+ end
125
+
126
+ previous = gen_time
127
+ end
128
+ return if previous <= time
129
+
130
+ _, ci, ai = entries.last
131
+ raise TimestampInvalidError.new("#{at(ci, ai)}: the time-stamp is later than the verification time",
132
+ chain_index: ci, ats_index: ai)
133
+ end
134
+
135
+ # Step 5. Each time-stamp must have been valid when the next one, which
136
+ # covers it, was made; the last one must be valid at the verification
137
+ # time.
138
+ def validate_timestamps!(validator, time)
139
+ list = entries
140
+ list.each_with_index do |(ats, ci, ai), k|
141
+ reference = k + 1 < list.size ? list[k + 1][0].timestamp.gen_time : time
142
+ valid = begin
143
+ validator.call(ats.timestamp, at: reference)
144
+ rescue TimestampInvalidError => e
145
+ raise TimestampInvalidError.new("#{at(ci, ai)}: #{e.message}", chain_index: ci, ats_index: ai)
146
+ end
147
+ next if valid
148
+
149
+ raise TimestampInvalidError.new("#{at(ci, ai)}: rejected by the time-stamp validator",
150
+ chain_index: ci, ats_index: ai)
151
+ end
152
+ end
153
+
154
+ def at(ci, ai)
155
+ "chain #{ci}, ArchiveTimeStamp #{ai}"
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "evidence_record/version"
4
+ require_relative "evidence_record/errors"
5
+ require_relative "evidence_record/digest"
6
+ require_relative "evidence_record/digest_algorithm"
7
+ require_relative "evidence_record/data"
8
+ require_relative "evidence_record/reduced_hashtree"
9
+ require_relative "evidence_record/hash_tree"
10
+ require_relative "evidence_record/der"
11
+ require_relative "evidence_record/algorithm_identifier"
12
+ require_relative "evidence_record/time_stamp_token"
13
+ require_relative "evidence_record/archive_time_stamp"
14
+ require_relative "evidence_record/chain"
15
+ require_relative "evidence_record/record"
16
+ require_relative "evidence_record/verifier"
17
+ require_relative "evidence_record/timestamp_validator"
18
+ require_relative "evidence_record/timestamp_exchange"
19
+ require_relative "evidence_record/generator"
20
+ require_relative "evidence_record/renewal"
21
+ require_relative "evidence_record/timestamp_renewal"
22
+ require_relative "evidence_record/hash_tree_renewal"
23
+
24
+ # Evidence Record Syntax (ERS), RFC 4998.
25
+ module EvidenceRecord
26
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: evidence_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken Tanaka
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Generation, timestamp renewal, hash-tree renewal and verification of
13
+ Evidence Records as defined in RFC 4998, with no runtime dependencies beyond the
14
+ Ruby standard library.
15
+ email:
16
+ - tanaka-ken@netlab.jp
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/evidence_record.rb
24
+ - lib/evidence_record/algorithm_identifier.rb
25
+ - lib/evidence_record/archive_time_stamp.rb
26
+ - lib/evidence_record/chain.rb
27
+ - lib/evidence_record/data.rb
28
+ - lib/evidence_record/der.rb
29
+ - lib/evidence_record/digest.rb
30
+ - lib/evidence_record/digest_algorithm.rb
31
+ - lib/evidence_record/errors.rb
32
+ - lib/evidence_record/generator.rb
33
+ - lib/evidence_record/hash_tree.rb
34
+ - lib/evidence_record/hash_tree_renewal.rb
35
+ - lib/evidence_record/record.rb
36
+ - lib/evidence_record/reduced_hashtree.rb
37
+ - lib/evidence_record/renewal.rb
38
+ - lib/evidence_record/time_stamp_token.rb
39
+ - lib/evidence_record/timestamp_exchange.rb
40
+ - lib/evidence_record/timestamp_renewal.rb
41
+ - lib/evidence_record/timestamp_validator.rb
42
+ - lib/evidence_record/verifier.rb
43
+ - lib/evidence_record/version.rb
44
+ homepage: https://github.com/xketanaka/evidence_record
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ source_code_uri: https://github.com/xketanaka/evidence_record
49
+ bug_tracker_uri: https://github.com/xketanaka/evidence_record/issues
50
+ documentation_uri: https://github.com/xketanaka/evidence_record/blob/main/README.md
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.4'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.9
67
+ specification_version: 4
68
+ summary: Evidence Record Syntax (ERS) compliant with RFC 4998
69
+ test_files: []