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,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# Internal: a strict DER reader and the few encoders the library needs
|
|
5
|
+
# (docs/design.md 7.1).
|
|
6
|
+
#
|
|
7
|
+
# OpenSSL::ASN1 is not used to check DER: re-encoding what it decoded keeps
|
|
8
|
+
# indefinite lengths and constructed strings, does not reorder SETs, and
|
|
9
|
+
# drops the fractional seconds of GeneralizedTime, so "decode and re-encode
|
|
10
|
+
# gives the same bytes" accepts BER and rejects valid DER. This reader also
|
|
11
|
+
# keeps the original bytes of every element, which are what gets hashed.
|
|
12
|
+
module DER
|
|
13
|
+
BOOLEAN = 1
|
|
14
|
+
INTEGER = 2
|
|
15
|
+
BIT_STRING = 3
|
|
16
|
+
OCTET_STRING = 4
|
|
17
|
+
NULL = 5
|
|
18
|
+
OBJECT_IDENTIFIER = 6
|
|
19
|
+
ENUMERATED = 10
|
|
20
|
+
RELATIVE_OID = 13
|
|
21
|
+
SEQUENCE = 16
|
|
22
|
+
SET = 17
|
|
23
|
+
UTC_TIME = 23
|
|
24
|
+
GENERALIZED_TIME = 24
|
|
25
|
+
|
|
26
|
+
# Universal types that are always constructed: EXTERNAL, EMBEDDED PDV,
|
|
27
|
+
# SEQUENCE and SET. All others must use the primitive encoding in DER.
|
|
28
|
+
CONSTRUCTED_TYPES = [8, 11, SEQUENCE, SET].freeze
|
|
29
|
+
TAG_CLASSES = %i[universal application context private].freeze
|
|
30
|
+
MAX_DEPTH = 64
|
|
31
|
+
GENERALIZED_TIME_PATTERN = /\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(?:\.(\d*[1-9]))?Z\z/
|
|
32
|
+
|
|
33
|
+
# One element (TLV). raw is its whole encoding, content its contents
|
|
34
|
+
# octets; children is an Array of Nodes for a constructed element.
|
|
35
|
+
class Node
|
|
36
|
+
attr_reader :tag_class, :tag, :raw, :content, :children
|
|
37
|
+
|
|
38
|
+
def initialize(tag_class, constructed, tag, raw, content, children)
|
|
39
|
+
@tag_class = tag_class
|
|
40
|
+
@constructed = constructed
|
|
41
|
+
@tag = tag
|
|
42
|
+
@raw = raw.freeze
|
|
43
|
+
@content = content.freeze
|
|
44
|
+
@children = children&.freeze
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def constructed?
|
|
49
|
+
@constructed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def universal?(tag)
|
|
53
|
+
@tag_class == :universal && @tag == tag
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def context?(tag)
|
|
57
|
+
@tag_class == :context && @tag == tag
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
# Parses one DER element that must span all of bytes. what names the
|
|
64
|
+
# input in error messages.
|
|
65
|
+
def parse(bytes, what)
|
|
66
|
+
raise ArgumentError, "#{what} must be a String, got #{bytes.class}" unless bytes.is_a?(String)
|
|
67
|
+
|
|
68
|
+
bytes = bytes.b
|
|
69
|
+
node, pos = read(bytes, 0, bytes.bytesize, 0, what)
|
|
70
|
+
raise ParseError, "#{what}: #{bytes.bytesize - pos} bytes of trailing data" unless pos == bytes.bytesize
|
|
71
|
+
|
|
72
|
+
node
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read(bytes, pos, limit, depth, what)
|
|
76
|
+
raise ParseError, "#{what}: nested too deeply" if depth > MAX_DEPTH
|
|
77
|
+
|
|
78
|
+
start = pos
|
|
79
|
+
id = byte_at(bytes, pos, limit, what)
|
|
80
|
+
pos += 1
|
|
81
|
+
tag_class = TAG_CLASSES[id >> 6]
|
|
82
|
+
constructed = id.anybits?(0x20)
|
|
83
|
+
tag = id & 0x1f
|
|
84
|
+
tag, pos = read_high_tag(bytes, pos, limit, what) if tag == 0x1f
|
|
85
|
+
length, pos = read_length(bytes, pos, limit, what)
|
|
86
|
+
raise ParseError, "#{what}: truncated" if limit - pos < length
|
|
87
|
+
|
|
88
|
+
children = nil
|
|
89
|
+
if constructed
|
|
90
|
+
children = []
|
|
91
|
+
child_pos = pos
|
|
92
|
+
while child_pos < pos + length
|
|
93
|
+
child, child_pos = read(bytes, child_pos, pos + length, depth + 1, what)
|
|
94
|
+
children << child
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
node = Node.new(tag_class, constructed, tag, bytes.byteslice(start, pos + length - start),
|
|
98
|
+
bytes.byteslice(pos, length), children)
|
|
99
|
+
check_universal(node, what) if tag_class == :universal
|
|
100
|
+
[node, pos + length]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def byte_at(bytes, pos, limit, what)
|
|
104
|
+
raise ParseError, "#{what}: truncated" if pos >= limit
|
|
105
|
+
|
|
106
|
+
bytes.getbyte(pos)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_high_tag(bytes, pos, limit, what)
|
|
110
|
+
tag = 0
|
|
111
|
+
loop do
|
|
112
|
+
b = byte_at(bytes, pos, limit, what)
|
|
113
|
+
raise ParseError, "#{what}: non-minimal tag number" if tag.zero? && b == 0x80
|
|
114
|
+
raise ParseError, "#{what}: tag number too large" if tag > 0xffffff
|
|
115
|
+
|
|
116
|
+
tag = (tag << 7) | (b & 0x7f)
|
|
117
|
+
pos += 1
|
|
118
|
+
break if b < 0x80
|
|
119
|
+
end
|
|
120
|
+
raise ParseError, "#{what}: non-minimal tag number" if tag < 0x1f
|
|
121
|
+
|
|
122
|
+
[tag, pos]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_length(bytes, pos, limit, what)
|
|
126
|
+
first = byte_at(bytes, pos, limit, what)
|
|
127
|
+
pos += 1
|
|
128
|
+
return [first, pos] if first < 0x80
|
|
129
|
+
raise ParseError, "#{what}: indefinite length is not allowed in DER" if first == 0x80
|
|
130
|
+
|
|
131
|
+
count = first & 0x7f
|
|
132
|
+
raise ParseError, "#{what}: length too large" if count > 4
|
|
133
|
+
raise ParseError, "#{what}: truncated" if limit - pos < count
|
|
134
|
+
|
|
135
|
+
length_bytes = bytes.byteslice(pos, count)
|
|
136
|
+
length = length_bytes.unpack1("H*").to_i(16)
|
|
137
|
+
if length_bytes.getbyte(0).zero? || length < 0x80
|
|
138
|
+
raise ParseError, "#{what}: length is not in its shortest form"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
[length, pos + count]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def check_universal(node, what)
|
|
145
|
+
tag = node.tag
|
|
146
|
+
raise ParseError, "#{what}: unexpected end-of-contents" if tag.zero?
|
|
147
|
+
if node.constructed? != CONSTRUCTED_TYPES.include?(tag)
|
|
148
|
+
raise ParseError, "#{what}: universal type #{tag} must use the " \
|
|
149
|
+
"#{node.constructed? ? 'primitive' : 'constructed'} encoding"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
c = node.content
|
|
153
|
+
ok = case tag
|
|
154
|
+
when BOOLEAN then ["\x00".b, "\xff".b].include?(c)
|
|
155
|
+
when INTEGER, ENUMERATED then minimal_integer?(c)
|
|
156
|
+
when NULL then c.empty?
|
|
157
|
+
when BIT_STRING then valid_bit_string?(c)
|
|
158
|
+
when OBJECT_IDENTIFIER, RELATIVE_OID then valid_oid_content?(c)
|
|
159
|
+
when SET then sorted?(node.children)
|
|
160
|
+
when UTC_TIME then c.match?(/\A\d{12}Z\z/)
|
|
161
|
+
when GENERALIZED_TIME then c.match?(GENERALIZED_TIME_PATTERN)
|
|
162
|
+
else true
|
|
163
|
+
end
|
|
164
|
+
raise ParseError, "#{what}: invalid DER encoding of universal type #{tag}" unless ok
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def minimal_integer?(c)
|
|
168
|
+
return false if c.empty?
|
|
169
|
+
return true if c.bytesize == 1
|
|
170
|
+
|
|
171
|
+
a = c.getbyte(0)
|
|
172
|
+
b = c.getbyte(1)
|
|
173
|
+
!((a.zero? && b < 0x80) || (a == 0xff && b >= 0x80))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def valid_bit_string?(c)
|
|
177
|
+
return false if c.empty?
|
|
178
|
+
|
|
179
|
+
unused = c.getbyte(0)
|
|
180
|
+
return unused.zero? if c.bytesize == 1
|
|
181
|
+
|
|
182
|
+
unused <= 7 && (c.getbyte(-1) & ((1 << unused) - 1)).zero?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def valid_oid_content?(c)
|
|
186
|
+
return false if c.empty? || c.getbyte(-1) >= 0x80
|
|
187
|
+
|
|
188
|
+
# no subidentifier may start with 0x80 (a non-minimal encoding)
|
|
189
|
+
c.each_byte.each_with_index.none? { |b, i| b == 0x80 && (i.zero? || c.getbyte(i - 1) < 0x80) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# DER sorts the elements of a SET by their encodings (X.690 11.6).
|
|
193
|
+
def sorted?(nodes)
|
|
194
|
+
nodes.each_cons(2).all? { |a, b| (a.raw <=> b.raw) <= 0 }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- normalizing lengths ---------------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Rewrites indefinite lengths and lengths not in their shortest form as
|
|
200
|
+
# definite, shortest lengths. Nothing else is changed: tags and the
|
|
201
|
+
# contents of primitive elements are copied as they are, so the result is
|
|
202
|
+
# DER only if the input was DER apart from its lengths (check it with
|
|
203
|
+
# parse). Used for time-stamp tokens from TSAs (docs/design.md 6).
|
|
204
|
+
def normalize_lengths(bytes, what)
|
|
205
|
+
raise ArgumentError, "#{what} must be a String, got #{bytes.class}" unless bytes.is_a?(String)
|
|
206
|
+
|
|
207
|
+
bytes = bytes.b
|
|
208
|
+
out, pos = normalize(bytes, 0, bytes.bytesize, 0, what)
|
|
209
|
+
raise ParseError, "#{what}: #{bytes.bytesize - pos} bytes of trailing data" unless pos == bytes.bytesize
|
|
210
|
+
|
|
211
|
+
out
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize(bytes, pos, limit, depth, what)
|
|
215
|
+
raise ParseError, "#{what}: nested too deeply" if depth > MAX_DEPTH
|
|
216
|
+
|
|
217
|
+
start = pos
|
|
218
|
+
id = byte_at(bytes, pos, limit, what)
|
|
219
|
+
pos += 1
|
|
220
|
+
_, pos = read_high_tag(bytes, pos, limit, what) if (id & 0x1f) == 0x1f
|
|
221
|
+
identifier = bytes.byteslice(start, pos - start)
|
|
222
|
+
constructed = id.anybits?(0x20)
|
|
223
|
+
|
|
224
|
+
first = byte_at(bytes, pos, limit, what)
|
|
225
|
+
if first == 0x80
|
|
226
|
+
raise ParseError, "#{what}: indefinite length on a primitive element" unless constructed
|
|
227
|
+
|
|
228
|
+
pos += 1
|
|
229
|
+
content = "".b
|
|
230
|
+
loop do
|
|
231
|
+
raise ParseError, "#{what}: missing end-of-contents" if limit - pos < 2
|
|
232
|
+
break if bytes.byteslice(pos, 2) == "\x00\x00".b
|
|
233
|
+
|
|
234
|
+
child, pos = normalize(bytes, pos, limit, depth + 1, what)
|
|
235
|
+
content << child
|
|
236
|
+
end
|
|
237
|
+
return [identifier + encode_length(content.bytesize) + content, pos + 2]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
length, pos = read_any_length(bytes, pos, limit, what)
|
|
241
|
+
raise ParseError, "#{what}: truncated" if limit - pos < length
|
|
242
|
+
|
|
243
|
+
content = if constructed
|
|
244
|
+
children = "".b
|
|
245
|
+
child_pos = pos
|
|
246
|
+
while child_pos < pos + length
|
|
247
|
+
child, child_pos = normalize(bytes, child_pos, pos + length, depth + 1, what)
|
|
248
|
+
children << child
|
|
249
|
+
end
|
|
250
|
+
children
|
|
251
|
+
else
|
|
252
|
+
bytes.byteslice(pos, length)
|
|
253
|
+
end
|
|
254
|
+
[identifier + encode_length(content.bytesize) + content, pos + length]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# A definite length in any form (BER).
|
|
258
|
+
def read_any_length(bytes, pos, limit, what)
|
|
259
|
+
first = byte_at(bytes, pos, limit, what)
|
|
260
|
+
pos += 1
|
|
261
|
+
return [first, pos] if first < 0x80
|
|
262
|
+
|
|
263
|
+
count = first & 0x7f
|
|
264
|
+
raise ParseError, "#{what}: length too large" if count > 8 || first == 0xff
|
|
265
|
+
raise ParseError, "#{what}: truncated" if limit - pos < count
|
|
266
|
+
|
|
267
|
+
[bytes.byteslice(pos, count).unpack1("H*").to_i(16), pos + count]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- reading values ------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def expect(node, what, tag)
|
|
273
|
+
raise ParseError, "#{what}: expected universal type #{tag}" unless node&.universal?(tag)
|
|
274
|
+
|
|
275
|
+
node
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The children of a SEQUENCE (or of the constructed element given by
|
|
279
|
+
# context:), checking how many there are.
|
|
280
|
+
def children_of(node, what, size: nil, context: nil)
|
|
281
|
+
ok = node && node.constructed? &&
|
|
282
|
+
(context ? node.context?(context) : node.universal?(SEQUENCE))
|
|
283
|
+
raise ParseError, "#{what}: expected #{context ? "[#{context}]" : 'a SEQUENCE'}" unless ok
|
|
284
|
+
if size && !(size === node.children.size)
|
|
285
|
+
raise ParseError, "#{what}: unexpected number of elements (#{node.children.size})"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
node.children
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The OID as a frozen dotted String.
|
|
292
|
+
def oid(node, what)
|
|
293
|
+
c = expect(node, what, OBJECT_IDENTIFIER).content
|
|
294
|
+
ids = []
|
|
295
|
+
value = 0
|
|
296
|
+
c.each_byte do |b|
|
|
297
|
+
value = (value << 7) | (b & 0x7f)
|
|
298
|
+
next if b >= 0x80
|
|
299
|
+
|
|
300
|
+
ids << value
|
|
301
|
+
value = 0
|
|
302
|
+
end
|
|
303
|
+
first = [ids[0] / 40, 2].min
|
|
304
|
+
[first, ids[0] - (first * 40), *ids[1..]].join(".").freeze
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def integer(node, what)
|
|
308
|
+
c = expect(node, what, INTEGER).content
|
|
309
|
+
value = c.unpack1("H*").to_i(16)
|
|
310
|
+
c.getbyte(0) >= 0x80 ? value - (1 << (8 * c.bytesize)) : value
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def octet_string(node, what)
|
|
314
|
+
expect(node, what, OCTET_STRING).content
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A frozen UTC Time, keeping fractions of a second. Frozen because Time
|
|
318
|
+
# has methods that change it (Time#localtime), and the Time is held by
|
|
319
|
+
# immutable objects (TimeStampToken#gen_time).
|
|
320
|
+
def generalized_time(node, what)
|
|
321
|
+
m = GENERALIZED_TIME_PATTERN.match(expect(node, what, GENERALIZED_TIME).content)
|
|
322
|
+
year, month, day, hour, minute, second = m.captures.first(6).map(&:to_i)
|
|
323
|
+
fraction = m[7] ? Rational(m[7].to_i, 10**m[7].size) : 0
|
|
324
|
+
time = Time.utc(year, month, day, hour, minute, second + fraction)
|
|
325
|
+
unless [time.year, time.month, time.day, time.hour, time.min, time.sec] ==
|
|
326
|
+
[year, month, day, hour, minute, second]
|
|
327
|
+
raise ParseError, "#{what}: invalid time"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
time.freeze
|
|
331
|
+
rescue ArgumentError
|
|
332
|
+
raise ParseError, "#{what}: invalid time"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# --- encoding --------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
# A TLV with a one-byte identifier.
|
|
338
|
+
def encode(identifier, content)
|
|
339
|
+
[identifier].pack("C") + encode_length(content.bytesize) + content.b
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def encode_length(length)
|
|
343
|
+
return [length].pack("C") if length < 0x80
|
|
344
|
+
|
|
345
|
+
bytes = [length.to_s(16).then { _1.size.odd? ? "0#{_1}" : _1 }].pack("H*")
|
|
346
|
+
[0x80 | bytes.bytesize].pack("C") + bytes
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def sequence(parts)
|
|
350
|
+
encode(0x20 | SEQUENCE, parts.map(&:b).join)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# A constructed context-specific element [tag] (tag < 31).
|
|
354
|
+
def context(tag, content)
|
|
355
|
+
encode(0xa0 | tag, content)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def encode_octet_string(bytes)
|
|
359
|
+
encode(OCTET_STRING, bytes)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def encode_integer(value)
|
|
363
|
+
OpenSSL::ASN1::Integer.new(value).to_der
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def encode_oid(dotted)
|
|
367
|
+
OpenSSL::ASN1::ObjectId.new(dotted).to_der
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# An immutable hash value. The bytes are always held as a frozen BINARY
|
|
5
|
+
# String. The same design as CTMerkle::Digest of the ct_merkle gem.
|
|
6
|
+
#
|
|
7
|
+
# A Digest does not know its hash algorithm: in RFC 4998 the algorithm is
|
|
8
|
+
# given by the context (the ArchiveTimeStamp or ArchiveTimeStampChain).
|
|
9
|
+
class Digest
|
|
10
|
+
HEX_PATTERN = /\A(?:[0-9a-fA-F]{2})*\z/
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def from_hex(str)
|
|
14
|
+
raise ArgumentError, "hex must be a String, got #{str.class}" unless str.is_a?(String)
|
|
15
|
+
raise ArgumentError, "invalid hex string: #{str.inspect}" unless HEX_PATTERN.match?(str)
|
|
16
|
+
|
|
17
|
+
new([str].pack("H*"))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def from_base64(str)
|
|
21
|
+
raise ArgumentError, "base64 must be a String, got #{str.class}" unless str.is_a?(String)
|
|
22
|
+
|
|
23
|
+
bytes = begin
|
|
24
|
+
str.unpack1("m0")
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
raise ArgumentError, "invalid base64 string: #{str.inspect}"
|
|
27
|
+
end
|
|
28
|
+
new(bytes)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the bytes of a Digest or a String as a frozen BINARY String.
|
|
32
|
+
# Used internally to accept both kinds of arguments.
|
|
33
|
+
def bytes_of(value, name = "hash")
|
|
34
|
+
case value
|
|
35
|
+
when Digest then value.to_binary
|
|
36
|
+
when String then value.b.freeze
|
|
37
|
+
else raise ArgumentError, "#{name} must be an EvidenceRecord::Digest or a binary String, got #{value.class}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wraps a Digest or a binary String as a Digest.
|
|
42
|
+
def wrap(value, name = "hash")
|
|
43
|
+
value.is_a?(Digest) ? value : new(bytes_of(value, name))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(bytes)
|
|
48
|
+
raise ArgumentError, "bytes must be a String, got #{bytes.class}" unless bytes.is_a?(String)
|
|
49
|
+
|
|
50
|
+
@bytes = bytes.b.freeze
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_binary
|
|
55
|
+
@bytes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_hex
|
|
59
|
+
@bytes.unpack1("H*")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_base64
|
|
63
|
+
[@bytes].pack("m0")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
alias to_s to_hex
|
|
67
|
+
|
|
68
|
+
def inspect
|
|
69
|
+
"#<#{self.class.name} #{to_hex}>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def bytesize
|
|
73
|
+
@bytes.bytesize
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compares bytes with another Digest or a String (encoding is ignored).
|
|
77
|
+
def ==(other)
|
|
78
|
+
case other
|
|
79
|
+
when Digest then @bytes == other.to_binary
|
|
80
|
+
when String then @bytes == other.b
|
|
81
|
+
else false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Only Digests are eql?, so that #hash stays consistent.
|
|
86
|
+
def eql?(other)
|
|
87
|
+
other.is_a?(Digest) && @bytes.eql?(other.to_binary)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def hash
|
|
91
|
+
[Digest, @bytes].hash
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module EvidenceRecord
|
|
6
|
+
# A supported hash algorithm. Only SHA-256, SHA-384 and SHA-512 are
|
|
7
|
+
# supported (docs/design.md 4.4).
|
|
8
|
+
#
|
|
9
|
+
# Instances are looked up with DigestAlgorithm.fetch (by Symbol) or
|
|
10
|
+
# DigestAlgorithm.from_oid; they are frozen singletons.
|
|
11
|
+
class DigestAlgorithm
|
|
12
|
+
attr_reader :name, :oid, :digest_size
|
|
13
|
+
|
|
14
|
+
def initialize(name, oid, openssl_name, digest_size)
|
|
15
|
+
@name = name
|
|
16
|
+
@oid = oid
|
|
17
|
+
@openssl_name = openssl_name
|
|
18
|
+
@digest_size = digest_size
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
SHA256 = new(:sha256, "2.16.840.1.101.3.4.2.1", "SHA256", 32)
|
|
23
|
+
SHA384 = new(:sha384, "2.16.840.1.101.3.4.2.2", "SHA384", 48)
|
|
24
|
+
SHA512 = new(:sha512, "2.16.840.1.101.3.4.2.3", "SHA512", 64)
|
|
25
|
+
|
|
26
|
+
ALL = [SHA256, SHA384, SHA512].freeze
|
|
27
|
+
BY_NAME = ALL.to_h { [_1.name, _1] }.freeze
|
|
28
|
+
BY_OID = ALL.to_h { [_1.oid, _1] }.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Accepts a Symbol (:sha256, :sha384, :sha512) or a DigestAlgorithm.
|
|
32
|
+
def fetch(value)
|
|
33
|
+
case value
|
|
34
|
+
when DigestAlgorithm then value
|
|
35
|
+
when Symbol
|
|
36
|
+
BY_NAME.fetch(value) do
|
|
37
|
+
raise UnsupportedAlgorithmError,
|
|
38
|
+
"unsupported digest algorithm #{value.inspect} (supported: #{BY_NAME.keys.map(&:inspect).join(', ')})"
|
|
39
|
+
end
|
|
40
|
+
else raise ArgumentError, "digest algorithm must be a Symbol, got #{value.class}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from_oid(oid)
|
|
45
|
+
BY_OID.fetch(oid) { raise UnsupportedAlgorithmError, "unsupported digest algorithm OID #{oid}" }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The hash of the concatenation of parts, as a BINARY String.
|
|
50
|
+
def digest(*parts)
|
|
51
|
+
d = new_openssl_digest
|
|
52
|
+
parts.each { d.update(_1) }
|
|
53
|
+
d.digest
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def new_openssl_digest
|
|
57
|
+
OpenSSL::Digest.new(@openssl_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_s
|
|
61
|
+
@name.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class.name} #{@name}>"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# Base class of all errors raised by this library.
|
|
5
|
+
# Invalid arguments are reported with the standard ArgumentError instead.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The input is not valid DER, or does not match the RFC 4998 structures.
|
|
9
|
+
class ParseError < Error; end
|
|
10
|
+
|
|
11
|
+
# A hash algorithm that this library does not support (only SHA-256,
|
|
12
|
+
# SHA-384 and SHA-512 are supported).
|
|
13
|
+
class UnsupportedAlgorithmError < Error; end
|
|
14
|
+
|
|
15
|
+
# The time-stamp response from a TSA cannot be used: it was rejected, or its
|
|
16
|
+
# message imprint does not match the request.
|
|
17
|
+
class TimestampResponseError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised by verify! when an Evidence Record does not verify.
|
|
20
|
+
#
|
|
21
|
+
# chain_index and ats_index tell where the verification failed. They are nil
|
|
22
|
+
# when the failure is not tied to a specific ArchiveTimeStamp.
|
|
23
|
+
class VerificationError < Error
|
|
24
|
+
attr_reader :chain_index, :ats_index
|
|
25
|
+
|
|
26
|
+
def initialize(message = nil, chain_index: nil, ats_index: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@chain_index = chain_index
|
|
29
|
+
@ats_index = ats_index
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The Evidence Record is structurally invalid: an empty chain, mixed hash
|
|
34
|
+
# algorithms within a chain, and so on.
|
|
35
|
+
class MalformedRecordError < VerificationError; end
|
|
36
|
+
|
|
37
|
+
# The data is not covered by the Evidence Record, or a computed root does not
|
|
38
|
+
# match the message imprint of its time-stamp.
|
|
39
|
+
class HashMismatchError < VerificationError; end
|
|
40
|
+
|
|
41
|
+
# The time-stamp validator rejected a time-stamp token.
|
|
42
|
+
class TimestampInvalidError < VerificationError; end
|
|
43
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvidenceRecord
|
|
4
|
+
# Builds Evidence Records for many data objects with one time-stamp
|
|
5
|
+
# (docs/design.md 3.3).
|
|
6
|
+
#
|
|
7
|
+
# gen = EvidenceRecord::Generator.new(digest_algorithm: :sha256)
|
|
8
|
+
# gen.add("doc-a") # => 0
|
|
9
|
+
# gen.add(EvidenceRecord::DataGroup.new([meta, body])) # => 1
|
|
10
|
+
# req = gen.timestamp_request(nonce: SecureRandom.random_number(2**64))
|
|
11
|
+
# res = post_to_tsa(req.to_der) # the TSA response, as a String
|
|
12
|
+
# records = gen.build(res) # => [Record, Record], in add order
|
|
13
|
+
#
|
|
14
|
+
# The data are hashed when they are added and are not kept. Data cannot be
|
|
15
|
+
# added once timestamp_request or build has been called, because the
|
|
16
|
+
# time-stamp covers the hash tree at that point.
|
|
17
|
+
#
|
|
18
|
+
# build checks that the response is granted and matches the request, but
|
|
19
|
+
# does not check its signature; verify the records for that.
|
|
20
|
+
#
|
|
21
|
+
# Not thread-safe.
|
|
22
|
+
class Generator
|
|
23
|
+
attr_reader :digest_algorithm
|
|
24
|
+
|
|
25
|
+
# digest_algorithm: :sha256, :sha384 or :sha512.
|
|
26
|
+
def initialize(digest_algorithm: :sha256)
|
|
27
|
+
@digest_algorithm = DigestAlgorithm.fetch(digest_algorithm)
|
|
28
|
+
@tree = HashTree.new(digest_algorithm: @digest_algorithm)
|
|
29
|
+
@groups = []
|
|
30
|
+
@closed = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Adds a data object (a String or a PrehashedData) or a DataGroup, and
|
|
34
|
+
# returns its index: the position of its record in the result of build.
|
|
35
|
+
def add(data)
|
|
36
|
+
raise Error, "data cannot be added after timestamp_request or build" if @closed
|
|
37
|
+
|
|
38
|
+
source = DataSource.wrap(data)
|
|
39
|
+
index = @tree.add(source.member_digests(@digest_algorithm))
|
|
40
|
+
@groups << source.group?
|
|
41
|
+
index
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The number of data objects and groups added.
|
|
45
|
+
def size
|
|
46
|
+
@tree.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The root of the hash tree: the hash value to be time-stamped.
|
|
50
|
+
def root
|
|
51
|
+
raise Error, "no data has been added" if @tree.empty?
|
|
52
|
+
|
|
53
|
+
@tree.root
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An OpenSSL::Timestamp::Request for the root, to be sent to a TSA. When
|
|
57
|
+
# nonce or policy_id is given, build checks that the response has the
|
|
58
|
+
# same values.
|
|
59
|
+
def timestamp_request(nonce: nil, cert_requested: true, policy_id: nil)
|
|
60
|
+
exchange.request(nonce: nonce, cert_requested: cert_requested, policy_id: policy_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds the records from the TSA response (a String, or an
|
|
64
|
+
# OpenSSL::Timestamp::Response). Returns an Array of Records in the order
|
|
65
|
+
# the data were added. Raises TimestampResponseError when the response
|
|
66
|
+
# is not usable.
|
|
67
|
+
def build(response)
|
|
68
|
+
timestamp = exchange.token(response)
|
|
69
|
+
Array.new(size) { |i| Record.build([Chain.build([archive_timestamp(i, timestamp)])]) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def exchange
|
|
75
|
+
@exchange ||= begin
|
|
76
|
+
imprint = root
|
|
77
|
+
@closed = true
|
|
78
|
+
TimestampExchange.new(@digest_algorithm, imprint)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A single data object time-stamped alone needs no reduced hash tree: its
|
|
83
|
+
# hash is the message imprint (as Bouncy Castle does).
|
|
84
|
+
def archive_timestamp(index, timestamp)
|
|
85
|
+
return ArchiveTimeStamp.build(timestamp: timestamp) if size == 1 && !@groups[0]
|
|
86
|
+
|
|
87
|
+
ArchiveTimeStamp.build(timestamp: timestamp, reduced_hashtree: @tree.reduced_hashtree(index),
|
|
88
|
+
digest_algorithm: @digest_algorithm)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|