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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/lib/evidence_record/algorithm_identifier.rb +37 -0
- data/lib/evidence_record/archive_time_stamp.rb +131 -0
- data/lib/evidence_record/chain.rb +54 -0
- data/lib/evidence_record/data.rb +143 -0
- data/lib/evidence_record/der.rb +370 -0
- data/lib/evidence_record/digest.rb +94 -0
- data/lib/evidence_record/digest_algorithm.rb +68 -0
- data/lib/evidence_record/errors.rb +43 -0
- data/lib/evidence_record/generator.rb +91 -0
- data/lib/evidence_record/hash_tree.rb +110 -0
- data/lib/evidence_record/hash_tree_renewal.rb +43 -0
- data/lib/evidence_record/record.rb +214 -0
- data/lib/evidence_record/reduced_hashtree.rb +73 -0
- data/lib/evidence_record/renewal.rb +62 -0
- data/lib/evidence_record/time_stamp_token.rb +140 -0
- data/lib/evidence_record/timestamp_exchange.rb +104 -0
- data/lib/evidence_record/timestamp_renewal.rb +49 -0
- data/lib/evidence_record/timestamp_validator.rb +72 -0
- data/lib/evidence_record/verifier.rb +158 -0
- data/lib/evidence_record/version.rb +5 -0
- data/lib/evidence_record.rb +26 -0
- metadata +69 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# A hash tree built from leaves, used to generate reduced hash trees
|
|
5
|
+
# (RFC 4998 §4.2). It only computes hashes; it knows nothing about ASN.1
|
|
6
|
+
# or time-stamps.
|
|
7
|
+
#
|
|
8
|
+
# Each leaf is the list of hashes of one data object (one hash) or of one
|
|
9
|
+
# data object group (the hashes of all its members). The tree is binary and
|
|
10
|
+
# built like Bouncy Castle's BinaryTreeRootCalculator (docs/design.md 4.2):
|
|
11
|
+
# the leaves are sorted by their hash, adjacent pairs are hashed together,
|
|
12
|
+
# and the last node of an odd-sized level is carried up unchanged.
|
|
13
|
+
class HashTree
|
|
14
|
+
attr_reader :digest_algorithm
|
|
15
|
+
|
|
16
|
+
def initialize(digest_algorithm: :sha256)
|
|
17
|
+
@digest_algorithm = DigestAlgorithm.fetch(digest_algorithm)
|
|
18
|
+
@leaves = []
|
|
19
|
+
@levels = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Adds a leaf from the hashes of a data object ([hash]) or of the members
|
|
23
|
+
# of a data object group, and returns the index of the new leaf (in the
|
|
24
|
+
# order of addition).
|
|
25
|
+
def add(member_hashes)
|
|
26
|
+
unless member_hashes.is_a?(Array) && !member_hashes.empty?
|
|
27
|
+
raise ArgumentError, "member_hashes must be a non-empty Array"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
values = member_hashes.map do |h|
|
|
31
|
+
bytes = Digest.bytes_of(h)
|
|
32
|
+
unless bytes.bytesize == @digest_algorithm.digest_size
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"hash must be #{@digest_algorithm.digest_size} bytes for #{@digest_algorithm}, got #{bytes.bytesize}"
|
|
35
|
+
end
|
|
36
|
+
bytes
|
|
37
|
+
end.freeze
|
|
38
|
+
@leaves << values
|
|
39
|
+
@levels = nil
|
|
40
|
+
@leaves.size - 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size
|
|
44
|
+
@leaves.size
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty?
|
|
48
|
+
@leaves.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The hash of the leaf at index (for a group, the hash of its members).
|
|
52
|
+
def leaf_hash(index)
|
|
53
|
+
check_index!(index)
|
|
54
|
+
Digest.new(node_hash(@leaves[index]))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def root
|
|
58
|
+
raise Error, "an empty hash tree has no root" if empty?
|
|
59
|
+
|
|
60
|
+
Digest.new(levels.last[0])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The reduced hash tree for the leaf at index (in the order of addition).
|
|
64
|
+
def reduced_hashtree(index)
|
|
65
|
+
check_index!(index)
|
|
66
|
+
all_levels = levels # also computes @positions
|
|
67
|
+
partials = [@leaves[index]]
|
|
68
|
+
pos = @positions[index]
|
|
69
|
+
all_levels[0...-1].each do |level|
|
|
70
|
+
sibling = pos ^ 1
|
|
71
|
+
partials << [level[sibling]] if sibling < level.size
|
|
72
|
+
pos >>= 1
|
|
73
|
+
end
|
|
74
|
+
ReducedHashtree.new(partials)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
"#<#{self.class.name} #{@digest_algorithm} size=#{size}>"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def node_hash(values)
|
|
84
|
+
ReducedHashtree.node_hash(@digest_algorithm, values)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# levels[0] holds the leaf hashes sorted in binary ascending order;
|
|
88
|
+
# levels.last holds the root.
|
|
89
|
+
def levels
|
|
90
|
+
return @levels if @levels
|
|
91
|
+
|
|
92
|
+
sorted = @leaves.each_with_index.map { |values, i| [node_hash(values), i] }.sort
|
|
93
|
+
@positions = Array.new(@leaves.size)
|
|
94
|
+
sorted.each_with_index { |(_, i), pos| @positions[i] = pos }
|
|
95
|
+
|
|
96
|
+
level = sorted.map(&:first)
|
|
97
|
+
levels = [level]
|
|
98
|
+
while level.size > 1
|
|
99
|
+
level = level.each_slice(2).map { |pair| pair.size == 2 ? @digest_algorithm.digest(*pair.sort) : pair[0] }
|
|
100
|
+
levels << level
|
|
101
|
+
end
|
|
102
|
+
@levels = levels
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_index!(index)
|
|
106
|
+
raise ArgumentError, "index must be an Integer, got #{index.class}" unless index.is_a?(Integer)
|
|
107
|
+
raise IndexError, "index #{index} out of range (size #{size})" unless index >= 0 && index < size
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# A hash-tree renewal of a Record (RFC 4998 §5.2, docs/design.md 3.5): a
|
|
5
|
+
# new chain, whose first time-stamp covers the data and all the older
|
|
6
|
+
# chains, hashed with a new algorithm. Done before the hash algorithm in
|
|
7
|
+
# use weakens.
|
|
8
|
+
#
|
|
9
|
+
# renewal = record.hash_tree_renewal(data: "doc-a", digest_algorithm: :sha512)
|
|
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 H(H(d) + ha) for the data object d, where ha
|
|
15
|
+
# is the hash of the ArchiveTimeStampSequence so far (both with the new
|
|
16
|
+
# algorithm). For a data object group, it covers H(H(d_i) + ha) for each
|
|
17
|
+
# member, as one group. The concatenation is in this fixed order, as
|
|
18
|
+
# Bouncy Castle does (docs/design.md 3.5).
|
|
19
|
+
#
|
|
20
|
+
# The record is first checked against the data (Record#verify_hashes!), so
|
|
21
|
+
# that a record is never renewed with the wrong data. The signatures of the
|
|
22
|
+
# time-stamps are not checked; do that with Record#verify before renewing.
|
|
23
|
+
#
|
|
24
|
+
# See Renewal for #timestamp_request and #build.
|
|
25
|
+
class HashTreeRenewal
|
|
26
|
+
include Renewal
|
|
27
|
+
|
|
28
|
+
# Internal: use Record#hash_tree_renewal.
|
|
29
|
+
def initialize(record, data:, digest_algorithm:)
|
|
30
|
+
algorithm = DigestAlgorithm.fetch(digest_algorithm)
|
|
31
|
+
source = DataSource.wrap(data)
|
|
32
|
+
Verifier.new(record, source).verify_hashes!
|
|
33
|
+
ha = algorithm.digest(record.archive_timestamp_sequence_der)
|
|
34
|
+
prepare(record, algorithm, source.member_digests(algorithm).map { algorithm.digest(_1, ha) })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def renewed_chains(archive_timestamp)
|
|
40
|
+
@record.chains + [Chain.build([archive_timestamp])]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# An Evidence Record (RFC 4998 §3): the RFC's EvidenceRecord type.
|
|
5
|
+
#
|
|
6
|
+
# EvidenceRecord ::= SEQUENCE {
|
|
7
|
+
# version INTEGER { v1(1) },
|
|
8
|
+
# digestAlgorithms SEQUENCE OF AlgorithmIdentifier,
|
|
9
|
+
# cryptoInfos [0] CryptoInfos OPTIONAL,
|
|
10
|
+
# encryptionInfo [1] EncryptionInfo OPTIONAL,
|
|
11
|
+
# archiveTimeStampSequence ArchiveTimeStampSequence }
|
|
12
|
+
#
|
|
13
|
+
# Only DER is accepted, and the original DER is kept and returned by
|
|
14
|
+
# #to_der (docs/design.md 7.1).
|
|
15
|
+
class Record
|
|
16
|
+
VERSION = 1
|
|
17
|
+
|
|
18
|
+
attr_reader :version, :digest_algorithm_oids, :chains
|
|
19
|
+
# The cryptoInfos and encryptionInfo fields as they were read (DER,
|
|
20
|
+
# including their [0] / [1] tags), or nil. They are kept but not
|
|
21
|
+
# interpreted (docs/design.md 7.3).
|
|
22
|
+
attr_reader :crypto_infos, :encryption_info
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Parses a DER-encoded Evidence Record. Raises ParseError when der is not
|
|
26
|
+
# DER or does not match the structure of RFC 4998.
|
|
27
|
+
def from_der(der)
|
|
28
|
+
new(DER.parse(der, "EvidenceRecord"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Internal: encodes a new Evidence Record from Chains. digestAlgorithms
|
|
32
|
+
# lists the algorithms of the ArchiveTimeStamps in order of appearance.
|
|
33
|
+
def build(chains)
|
|
34
|
+
from_der(encode(digest_algorithm_ders([], chains), nil, nil, chains))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Internal.
|
|
38
|
+
def encode(digest_algorithm_ders, crypto_infos, encryption_info, chains)
|
|
39
|
+
DER.sequence([DER.encode_integer(VERSION), DER.sequence(digest_algorithm_ders),
|
|
40
|
+
crypto_infos, encryption_info, DER.sequence(chains.map(&:to_der))].compact)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Internal: existing (OID, DER) pairs plus AlgorithmIdentifiers for the
|
|
44
|
+
# algorithms of chains that are not among them.
|
|
45
|
+
def digest_algorithm_ders(existing, chains)
|
|
46
|
+
oids = existing.map(&:first)
|
|
47
|
+
added = chains.flat_map(&:archive_timestamps).map(&:digest_algorithm_oid).uniq - oids
|
|
48
|
+
existing.map(&:last) + added.map { AlgorithmIdentifier.encode(DigestAlgorithm.from_oid(_1)) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private :new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(node)
|
|
55
|
+
what = "EvidenceRecord"
|
|
56
|
+
@der = node.raw
|
|
57
|
+
version, algorithms, *optional, sequence = DER.children_of(node, what, size: 3..5)
|
|
58
|
+
@version = DER.integer(version, "#{what} version")
|
|
59
|
+
raise ParseError, "#{what}: unsupported version #{@version}" unless @version == VERSION
|
|
60
|
+
|
|
61
|
+
@digest_algorithms = DER.children_of(algorithms, "#{what} digestAlgorithms").map do |alg|
|
|
62
|
+
[AlgorithmIdentifier.parse(alg, "#{what} digestAlgorithms"), alg.raw]
|
|
63
|
+
end.freeze
|
|
64
|
+
@digest_algorithm_oids = @digest_algorithms.map(&:first).freeze
|
|
65
|
+
parse_optional(optional, what)
|
|
66
|
+
@chains = DER.children_of(sequence, "#{what} archiveTimeStampSequence").each_with_index.map do |chain, i|
|
|
67
|
+
Chain.from_node(chain, "archiveTimeStampSequence[#{i}]")
|
|
68
|
+
end.freeze
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The digestAlgorithms field as DigestAlgorithms. Raises
|
|
73
|
+
# UnsupportedAlgorithmError if it lists other algorithms (see
|
|
74
|
+
# #digest_algorithm_oids).
|
|
75
|
+
def digest_algorithms
|
|
76
|
+
@digest_algorithm_oids.map { DigestAlgorithm.from_oid(_1) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The time the data is proven to have existed: the genTime of the first
|
|
80
|
+
# ArchiveTimeStamp. nil if the record has no ArchiveTimeStamp.
|
|
81
|
+
#
|
|
82
|
+
# This is only meaningful after the record has been verified.
|
|
83
|
+
def proven_time
|
|
84
|
+
@chains.first&.archive_timestamps&.first&.timestamp&.gen_time
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether other was built from the same hash tree: the first time-stamps
|
|
88
|
+
# are the same. Such records can share the TSA response of a time-stamp
|
|
89
|
+
# renewal.
|
|
90
|
+
def related_to?(other)
|
|
91
|
+
raise ArgumentError, "other must be an EvidenceRecord::Record, got #{other.class}" unless other.is_a?(Record)
|
|
92
|
+
|
|
93
|
+
mine = @chains.first&.archive_timestamps&.first
|
|
94
|
+
theirs = other.chains.first&.archive_timestamps&.first
|
|
95
|
+
!mine.nil? && !theirs.nil? && mine.timestamp == theirs.timestamp
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Verifies that this record proves the existence of data (docs/design.md
|
|
99
|
+
# 3.6, 5.1): the hash chain from data to every time-stamp, the order of
|
|
100
|
+
# the time-stamps, and the validity of each time-stamp, checked with
|
|
101
|
+
# timestamp_validator (see TimestampValidator).
|
|
102
|
+
#
|
|
103
|
+
# data is a String, a PrehashedData or a DataGroup. at is the time of
|
|
104
|
+
# verification; the last time-stamp must be valid at that time.
|
|
105
|
+
#
|
|
106
|
+
# Returns true or false. Invalid arguments raise ArgumentError, and
|
|
107
|
+
# unsupported hash algorithms raise UnsupportedAlgorithmError.
|
|
108
|
+
def verify(data:, timestamp_validator:, at: Time.now)
|
|
109
|
+
verify!(data: data, timestamp_validator: timestamp_validator, at: at)
|
|
110
|
+
rescue VerificationError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Like #verify, but raises a VerificationError telling why and where the
|
|
115
|
+
# verification failed.
|
|
116
|
+
def verify!(data:, timestamp_validator:, at: Time.now)
|
|
117
|
+
unless timestamp_validator.respond_to?(:call)
|
|
118
|
+
raise ArgumentError, "timestamp_validator must respond to call(token, at:)"
|
|
119
|
+
end
|
|
120
|
+
raise ArgumentError, "at must be a Time, got #{at.class}" unless at.is_a?(Time)
|
|
121
|
+
|
|
122
|
+
Verifier.new(self, data).verify!(timestamp_validator: timestamp_validator, at: at)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Verifies only the structure and the hash chain from data to every
|
|
126
|
+
# time-stamp. Neither the signatures nor the times of the time-stamps are
|
|
127
|
+
# checked, so this alone does not prove anything about time.
|
|
128
|
+
def verify_hashes(data:)
|
|
129
|
+
verify_hashes!(data: data)
|
|
130
|
+
rescue VerificationError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Like #verify_hashes, but raises a VerificationError.
|
|
135
|
+
def verify_hashes!(data:)
|
|
136
|
+
Verifier.new(self, data).verify_hashes!
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A time-stamp renewal of this record (TimestampRenewal): a new time-stamp
|
|
140
|
+
# covering the time-stamps of the last chain. Raises MalformedRecordError
|
|
141
|
+
# when the record has no ArchiveTimeStamp, and UnsupportedAlgorithmError
|
|
142
|
+
# when the last chain uses an unsupported hash algorithm.
|
|
143
|
+
def timestamp_renewal
|
|
144
|
+
TimestampRenewal.new(self)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A hash-tree renewal of this record (HashTreeRenewal): a new chain with
|
|
148
|
+
# the hash algorithm digest_algorithm (:sha256, :sha384 or :sha512),
|
|
149
|
+
# covering data and the older chains.
|
|
150
|
+
#
|
|
151
|
+
# data is a String, a PrehashedData or a DataGroup, as for #verify; a
|
|
152
|
+
# PrehashedData needs hashes for the algorithms of the record and for
|
|
153
|
+
# digest_algorithm. The record is first checked against data with
|
|
154
|
+
# #verify_hashes!, which raises a VerificationError when they do not
|
|
155
|
+
# match.
|
|
156
|
+
def hash_tree_renewal(data:, digest_algorithm:)
|
|
157
|
+
HashTreeRenewal.new(self, data: data, digest_algorithm: digest_algorithm)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def to_der
|
|
161
|
+
@der
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ==(other)
|
|
165
|
+
other.is_a?(Record) && to_der == other.to_der
|
|
166
|
+
end
|
|
167
|
+
alias eql? ==
|
|
168
|
+
|
|
169
|
+
def hash
|
|
170
|
+
[Record, @der].hash
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def inspect
|
|
174
|
+
"#<#{self.class.name} digest_algorithm_oids=#{@digest_algorithm_oids} chains=#{@chains.inspect}>"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Internal: the DER of an ArchiveTimeStampSequence holding the first count
|
|
178
|
+
# chains, whose hash (ha) is used by a hash-tree renewal (RFC 4998 §5.2).
|
|
179
|
+
def archive_timestamp_sequence_der(count = @chains.size)
|
|
180
|
+
DER.sequence(@chains.first(count).map(&:to_der))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Internal: a new Record with chains replaced, keeping the other fields as
|
|
184
|
+
# they are and adding the algorithms of new chains to digestAlgorithms.
|
|
185
|
+
def with_chains(chains)
|
|
186
|
+
algorithms = self.class.digest_algorithm_ders(@digest_algorithms, chains)
|
|
187
|
+
self.class.from_der(self.class.encode(algorithms, @crypto_infos, @encryption_info, chains))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def parse_optional(nodes, what)
|
|
193
|
+
tags = nodes.map { |n| n.tag_class == :context && n.constructed? ? n.tag : nil }
|
|
194
|
+
raise ParseError, "#{what}: unexpected elements" unless [[], [0], [1], [0, 1]].include?(tags)
|
|
195
|
+
|
|
196
|
+
nodes.each do |node|
|
|
197
|
+
if node.tag.zero?
|
|
198
|
+
# CryptoInfos ::= SEQUENCE SIZE (1..MAX) OF Attribute
|
|
199
|
+
unless !node.children.empty? && node.children.all? { _1.universal?(DER::SEQUENCE) }
|
|
200
|
+
raise ParseError, "#{what}: invalid cryptoInfos"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
@crypto_infos = node.raw
|
|
204
|
+
else
|
|
205
|
+
# EncryptionInfo ::= SEQUENCE { encryptionInfoType OBJECT IDENTIFIER,
|
|
206
|
+
# encryptionInfoValue ANY DEFINED BY encryptionInfoType }
|
|
207
|
+
type, = DER.children_of(node, "#{what} encryptionInfo", size: 2, context: 1)
|
|
208
|
+
DER.oid(type, "#{what} encryptionInfo")
|
|
209
|
+
@encryption_info = node.raw
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# A reduced hash tree (RFC 4998 §4.1): the sequence of partial hash trees
|
|
5
|
+
# needed to recompute the root from one leaf. The first partial hash tree
|
|
6
|
+
# holds the leaf itself (for a data object group, the hashes of all its
|
|
7
|
+
# members) and possibly its siblings; each following one holds the siblings
|
|
8
|
+
# at the next level.
|
|
9
|
+
#
|
|
10
|
+
# The root is computed with the generic RFC algorithm, so trees of any shape
|
|
11
|
+
# (binary or n-ary) are supported (docs/design.md 4.1, 5.2).
|
|
12
|
+
class ReducedHashtree
|
|
13
|
+
attr_reader :partial_hashtrees
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# The hash of one node from the values that are hashed together:
|
|
17
|
+
# a single value is used as it is, several values are sorted in binary
|
|
18
|
+
# ascending order, concatenated and hashed.
|
|
19
|
+
def node_hash(algorithm, values)
|
|
20
|
+
return values[0] if values.size == 1
|
|
21
|
+
|
|
22
|
+
algorithm.digest(*values.sort)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# partial_hashtrees: an Array of non-empty Arrays of Digests (or binary
|
|
27
|
+
# Strings).
|
|
28
|
+
def initialize(partial_hashtrees)
|
|
29
|
+
unless partial_hashtrees.is_a?(Array) && !partial_hashtrees.empty?
|
|
30
|
+
raise ArgumentError, "partial_hashtrees must be a non-empty Array"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@partial_hashtrees = partial_hashtrees.each_with_index.map do |values, i|
|
|
34
|
+
unless values.is_a?(Array) && !values.empty?
|
|
35
|
+
raise ArgumentError, "partial hash tree #{i} must be a non-empty Array"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
values.map { Digest.wrap(_1, "partial hash tree #{i} value") }.freeze
|
|
39
|
+
end.freeze
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The first partial hash tree: the leaf, or the members of a data object
|
|
44
|
+
# group, possibly with siblings.
|
|
45
|
+
def leaf_values
|
|
46
|
+
@partial_hashtrees[0]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The root hash recomputed with algorithm (a Symbol or DigestAlgorithm).
|
|
50
|
+
#
|
|
51
|
+
# The lengths of the values are not checked here; the verifier does that.
|
|
52
|
+
def root(algorithm)
|
|
53
|
+
algorithm = DigestAlgorithm.fetch(algorithm)
|
|
54
|
+
first, *rest = @partial_hashtrees.map { |values| values.map(&:to_binary) }
|
|
55
|
+
current = self.class.node_hash(algorithm, first)
|
|
56
|
+
rest.each { |values| current = algorithm.digest(*([current] + values).sort) }
|
|
57
|
+
Digest.new(current)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ==(other)
|
|
61
|
+
other.is_a?(ReducedHashtree) && @partial_hashtrees == other.partial_hashtrees
|
|
62
|
+
end
|
|
63
|
+
alias eql? ==
|
|
64
|
+
|
|
65
|
+
def hash
|
|
66
|
+
[ReducedHashtree, @partial_hashtrees].hash
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect
|
|
70
|
+
"#<#{self.class.name} #{@partial_hashtrees.map { |v| v.map { _1.to_hex[0, 8] } }.inspect}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# Internal: what TimestampRenewal and HashTreeRenewal share. The including
|
|
5
|
+
# class sets @record, @digest_algorithm and @values (the hashes covered by
|
|
6
|
+
# the new time-stamp, as one data object group) with #prepare, and
|
|
7
|
+
# implements #renewed_chains(archive_timestamp).
|
|
8
|
+
module Renewal
|
|
9
|
+
attr_reader :record, :digest_algorithm, :root
|
|
10
|
+
|
|
11
|
+
# An OpenSSL::Timestamp::Request for the root, to be sent to a TSA. When
|
|
12
|
+
# nonce or policy_id is given, build checks that the response has the
|
|
13
|
+
# same values.
|
|
14
|
+
def timestamp_request(nonce: nil, cert_requested: true, policy_id: nil)
|
|
15
|
+
@exchange.request(nonce: nonce, cert_requested: cert_requested, policy_id: policy_id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Builds the renewed Record from the TSA response (a String, or an
|
|
19
|
+
# OpenSSL::Timestamp::Response). Raises TimestampResponseError when the
|
|
20
|
+
# response is not usable, including when its time-stamp is older than
|
|
21
|
+
# the last one of the record.
|
|
22
|
+
#
|
|
23
|
+
# The signature of the time-stamp is not checked; verify the new record
|
|
24
|
+
# for that.
|
|
25
|
+
def build(response)
|
|
26
|
+
timestamp = @exchange.token(response)
|
|
27
|
+
last = @record.chains.last.archive_timestamps.last.timestamp
|
|
28
|
+
if timestamp.gen_time < last.gen_time
|
|
29
|
+
raise TimestampResponseError, "the time-stamp is older than the last time-stamp of the record"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@record.with_chains(renewed_chains(archive_timestamp(timestamp)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect
|
|
36
|
+
"#<#{self.class.name} #{@digest_algorithm} root=#{@root.to_hex}>"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def prepare(record, digest_algorithm, values)
|
|
42
|
+
@record = record
|
|
43
|
+
@digest_algorithm = digest_algorithm
|
|
44
|
+
@tree = HashTree.new(digest_algorithm: digest_algorithm)
|
|
45
|
+
@tree.add(values)
|
|
46
|
+
@single = values.size == 1
|
|
47
|
+
@root = @tree.root
|
|
48
|
+
@exchange = TimestampExchange.new(digest_algorithm, @root)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# With a single hash to cover, the hash is the message imprint and no
|
|
52
|
+
# reduced hash tree is needed. Otherwise the reduced hash tree is the one
|
|
53
|
+
# partial hash tree holding the hashes. Both as Bouncy Castle does
|
|
54
|
+
# (ERSArchiveTimeStampGenerator).
|
|
55
|
+
def archive_timestamp(timestamp)
|
|
56
|
+
return ArchiveTimeStamp.build(timestamp: timestamp) if @single
|
|
57
|
+
|
|
58
|
+
ArchiveTimeStamp.build(timestamp: timestamp, reduced_hashtree: @tree.reduced_hashtree(0),
|
|
59
|
+
digest_algorithm: @digest_algorithm)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# An RFC 3161 time-stamp token: the timeStamp field of an ArchiveTimeStamp.
|
|
5
|
+
#
|
|
6
|
+
# The fields of TSTInfo are read with this library's DER reader, not with
|
|
7
|
+
# OpenSSL::Timestamp, because Ruby's OpenSSL cannot parse tokens that carry
|
|
8
|
+
# OCSP responses in the CMS crls field (docs/design.md 7.2). The token's
|
|
9
|
+
# signature is not checked here; that is the job of a timestamp validator.
|
|
10
|
+
#
|
|
11
|
+
# The original DER is kept and returned by #to_der.
|
|
12
|
+
class TimeStampToken
|
|
13
|
+
ID_SIGNED_DATA = "1.2.840.113549.1.7.2"
|
|
14
|
+
ID_CT_TST_INFO = "1.2.840.113549.1.9.16.1.4"
|
|
15
|
+
|
|
16
|
+
attr_reader :gen_time, :message_imprint, :hash_algorithm_oid, :serial_number, :policy_id, :nonce
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def from_der(der)
|
|
20
|
+
from_node(DER.parse(der, "time-stamp token"), "time-stamp token")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Internal: from an already parsed element.
|
|
24
|
+
def from_node(node, what)
|
|
25
|
+
new(node, what)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private :new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(node, what)
|
|
32
|
+
@der = node.raw
|
|
33
|
+
content_type, wrapper = DER.children_of(node, "#{what} (ContentInfo)", size: 2)
|
|
34
|
+
unless DER.oid(content_type, what) == ID_SIGNED_DATA
|
|
35
|
+
raise ParseError, "#{what}: only RFC 3161 time-stamp tokens (CMS SignedData) are supported"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@signed_data = DER.children_of(wrapper, what, size: 1, context: 0)[0]
|
|
39
|
+
@certificate_ders = parse_signed_data(@signed_data, "#{what} (SignedData)")
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The hash algorithm of the message imprint, as a DigestAlgorithm.
|
|
44
|
+
# Raises UnsupportedAlgorithmError for other algorithms (see
|
|
45
|
+
# #hash_algorithm_oid).
|
|
46
|
+
def hash_algorithm
|
|
47
|
+
DigestAlgorithm.from_oid(@hash_algorithm_oid)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The certificates included in the token (normally the TSA certificate and
|
|
51
|
+
# possibly its chain), as OpenSSL::X509::Certificate.
|
|
52
|
+
def certificates
|
|
53
|
+
@certificate_ders.map { OpenSSL::X509::Certificate.new(_1) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_der
|
|
57
|
+
@der
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Internal: a copy of the token without the CMS crls field, which Ruby's
|
|
61
|
+
# OpenSSL cannot parse when it holds OCSP responses. The field is not
|
|
62
|
+
# signed, so removing it does not change the result of verifying the
|
|
63
|
+
# signature (docs/design.md 7.2). Never hash this copy.
|
|
64
|
+
def der_without_crls
|
|
65
|
+
kept = @signed_data.children.reject { _1.context?(1) }
|
|
66
|
+
return @der if kept.size == @signed_data.children.size
|
|
67
|
+
|
|
68
|
+
DER.sequence([DER.encode_oid(ID_SIGNED_DATA), DER.context(0, DER.sequence(kept.map(&:raw)))])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ==(other)
|
|
72
|
+
other.is_a?(TimeStampToken) && to_der == other.to_der
|
|
73
|
+
end
|
|
74
|
+
alias eql? ==
|
|
75
|
+
|
|
76
|
+
def hash
|
|
77
|
+
[TimeStampToken, @der].hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inspect
|
|
81
|
+
"#<#{self.class.name} gen_time=#{@gen_time.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")} " \
|
|
82
|
+
"message_imprint=#{@message_imprint.to_hex} serial_number=#{@serial_number}>"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# SignedData ::= SEQUENCE { version, digestAlgorithms SET, encapContentInfo,
|
|
88
|
+
# certificates [0] IMPLICIT OPTIONAL, crls [1] IMPLICIT OPTIONAL,
|
|
89
|
+
# signerInfos SET }
|
|
90
|
+
def parse_signed_data(node, what)
|
|
91
|
+
children = DER.children_of(node, what, size: 4..6)
|
|
92
|
+
DER.integer(children[0], "#{what} version")
|
|
93
|
+
DER.expect(children[1], "#{what} digestAlgorithms", DER::SET)
|
|
94
|
+
DER.expect(children[-1], "#{what} signerInfos", DER::SET)
|
|
95
|
+
optional = children[3...-1]
|
|
96
|
+
tags = optional.map { |n| n.tag_class == :context && n.constructed? ? n.tag : nil }
|
|
97
|
+
unless [[], [0], [1], [0, 1]].include?(tags)
|
|
98
|
+
raise ParseError, "#{what}: unexpected elements before signerInfos"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
parse_encap_content_info(children[2], "#{what} encapContentInfo")
|
|
102
|
+
certificates = optional.find { _1.tag.zero? }
|
|
103
|
+
(certificates&.children || []).select { _1.universal?(DER::SEQUENCE) }.map(&:raw)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_encap_content_info(node, what)
|
|
107
|
+
content_type, wrapper = DER.children_of(node, what, size: 2)
|
|
108
|
+
unless DER.oid(content_type, what) == ID_CT_TST_INFO
|
|
109
|
+
raise ParseError, "#{what}: the content is not a TSTInfo"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
econtent = DER.octet_string(DER.children_of(wrapper, what, size: 1, context: 0)[0], what)
|
|
113
|
+
parse_tst_info(DER.parse(econtent, "TSTInfo"), "TSTInfo")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# TSTInfo ::= SEQUENCE { version INTEGER { v1(1) }, policy, messageImprint,
|
|
117
|
+
# serialNumber, genTime, accuracy OPTIONAL, ordering BOOLEAN DEFAULT FALSE,
|
|
118
|
+
# nonce INTEGER OPTIONAL, tsa [0] OPTIONAL, extensions [1] OPTIONAL }
|
|
119
|
+
def parse_tst_info(node, what)
|
|
120
|
+
version, policy, imprint, serial, gen_time, *rest = DER.children_of(node, what, size: 5..10)
|
|
121
|
+
raise ParseError, "#{what}: unsupported version" unless DER.integer(version, "#{what} version") == 1
|
|
122
|
+
|
|
123
|
+
@policy_id = DER.oid(policy, "#{what} policy")
|
|
124
|
+
parse_message_imprint(imprint, "#{what} messageImprint")
|
|
125
|
+
@serial_number = DER.integer(serial, "#{what} serialNumber")
|
|
126
|
+
@gen_time = DER.generalized_time(gen_time, "#{what} genTime")
|
|
127
|
+
@nonce = rest.find { _1.universal?(DER::INTEGER) }&.then { DER.integer(_1, "#{what} nonce") }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse_message_imprint(node, what)
|
|
131
|
+
algorithm, hashed = DER.children_of(node, what, size: 2)
|
|
132
|
+
@hash_algorithm_oid = AlgorithmIdentifier.parse(algorithm, "#{what} hashAlgorithm")
|
|
133
|
+
@message_imprint = Digest.new(DER.octet_string(hashed, "#{what} hashedMessage"))
|
|
134
|
+
known = DigestAlgorithm::BY_OID[@hash_algorithm_oid]
|
|
135
|
+
return if known.nil? || known.digest_size == @message_imprint.bytesize
|
|
136
|
+
|
|
137
|
+
raise ParseError, "#{what}: the #{known} hash must be #{known.digest_size} bytes"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|