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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bbd1dbdd156796ea4d19b859ab779f227e646dd67bbdc3395a82e322bef66831
4
+ data.tar.gz: ea49206348d9bf5e361375613b0bc7b0e6a6cbb3790efc0333abc88541e331c4
5
+ SHA512:
6
+ metadata.gz: 2b93957420913d30bfbef1c4387207dc1796100c3ceabde8b794e666b775e7f84b85c1c56685dbd3ab69b4fab25ea9dc5848f39052952612ae977194c015a4f9
7
+ data.tar.gz: 80225299d4546a4fcd95ea35baa382ed8c5b8d0ed834ff9d2e8b5a3c2d5a95b5b08c19806de6b95f040a26a5be208bb6ece4d62ac367f23015907ffeb3ce5bd2
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ken Tanaka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # evidence_record
2
+
3
+ [RFC 4998](https://www.rfc-editor.org/rfc/rfc4998)(Evidence Record Syntax, ERS)に準拠した、Evidence Record の Ruby 実装。
4
+
5
+ Evidence Record とは、データがある時点に存在しそれ以降改ざんされてないことを長期間にわたって証明するデータ構造。多数のデータをハッシュツリーにまとめてタイムスタンプ(RFC 3161)で保護する。タイムスタンプやハッシュアルゴリズムがが弱くなる前に新しいタイムスタンプやハッシュアルゴリズムに更新することができる。
6
+
7
+ ## 機能
8
+
9
+ - 文字列または計算済みのハッシュ値を対象に Evidence Record を生成
10
+ - 多数のデータの Evidence Record を、1 つのタイムスタンプでまとめて生成
11
+ - タイムスタンプ更新(TSA の証明書の期限が切れる前にタイムスタンプを掛け直す)、ハッシュツリー更新(新しいハッシュアルゴリズムに移行する)
12
+ - 外部から与えられた Evidence Record の検証。任意のハッシュツリーを RFC 4998 の手順で検証する
13
+ - タイムスタンプの検証は差し替え可能。OpenSSL で署名と証明書チェーンを検証する検証器を同梱
14
+ - TSA との通信は行わない
15
+ - [BSI のテストデータ](https://github.com/bcgit/bc-test-data)で動作を確認済み。[Bouncy Castle](https://www.bouncycastle.org/) と双方向に相互運用できることを確認済み
16
+ - 依存ライブラリなし
17
+
18
+ ## インストール
19
+
20
+ ```bash
21
+ gem install evidence_record
22
+
23
+ # or
24
+
25
+ bundle add evidence_record
26
+ ```
27
+
28
+ ## 使い方
29
+
30
+ ### 生成
31
+
32
+ ```ruby
33
+ gen = EvidenceRecord::Generator.new(digest_algorithm: :sha256)
34
+ gen.add("doc-a")
35
+ gen.add(EvidenceRecord::DataGroup.new([meta_xml, body_pdf])) # グループは 1 つの Evidence Record になる
36
+
37
+ # タイムスタンプ要求(OpenSSL::Timestamp::Request)を作り、TSA に送る
38
+ req = gen.timestamp_request(nonce: SecureRandom.random_number(2**64))
39
+ res = post_to_tsa(req.to_der) # res: TSA の応答(TimeStampResp のバイト列)
40
+
41
+ # add した順に Evidence Record が返る
42
+ records = gen.build(res) # => [Record, Record]
43
+ File.binwrite("doc-a.ers", records[0].to_der)
44
+ ```
45
+
46
+ タイムスタンプ取得(post_to_tsa)の実装例
47
+
48
+ ```ruby
49
+ def post_to_tsa(der)
50
+ uri = URI("https://tsa.example.com/")
51
+ res = Net::HTTP.post(uri, der, "Content-Type" => "application/timestamp-query")
52
+ raise "TSA error: #{res.code}" unless res.is_a?(Net::HTTPSuccess)
53
+
54
+ res.body
55
+ end
56
+ ```
57
+
58
+ ### 検証
59
+
60
+ ```ruby
61
+ record = EvidenceRecord::Record.from_der(File.binread("doc-a.ers"))
62
+
63
+ # TSA の証明書チェーンを検証する
64
+ store = OpenSSL::X509::Store.new
65
+ store.add_cert(tsa_root_certificate)
66
+ validator = EvidenceRecord::TimestampValidator::OpenSSL.new(store: store)
67
+
68
+ record.verify(data: "doc-a", timestamp_validator: validator) # => true / false
69
+ record.verify!(data: "doc-a", timestamp_validator: validator) # 失敗すると例外
70
+ record.proven_time # データの存在が証明される時刻(タイムスタンプの genTime)
71
+ ```
72
+
73
+ 検証では、データからすべてのタイムスタンプまでのハッシュの連鎖、各タイムスタンプの正当性を確認する。
74
+
75
+ ### タイムスタンプ更新
76
+
77
+ TSA の証明書の期限が切れる前にタイムスタンプを掛け直す
78
+
79
+ ```ruby
80
+ renewal = record.timestamp_renewal
81
+ req = renewal.timestamp_request(nonce: SecureRandom.random_number(2**64))
82
+ record2 = renewal.build(post_to_tsa(req.to_der)) # => 新しい Record
83
+ ```
84
+
85
+ ### ハッシュツリー更新
86
+
87
+ 使っているハッシュアルゴリズムが弱くなった際に新しいアルゴリズムに移行する。データが必要。
88
+
89
+ ```ruby
90
+ renewal = record.hash_tree_renewal(data: "doc-a", digest_algorithm: :sha512)
91
+ req = renewal.timestamp_request(nonce: SecureRandom.random_number(2**64))
92
+ record3 = renewal.build(post_to_tsa(req.to_der)) # => 新しい Chain が追加された Record
93
+ ```
94
+
95
+ ### 計算済みのハッシュ値
96
+
97
+ データ本体の代わりにあらかじめ手計算したハッシュ値を指定可能。
98
+
99
+ ```ruby
100
+ pre = EvidenceRecord::PrehashedData.new(
101
+ sha256: OpenSSL::Digest.new("SHA256").file("large.bin").digest,
102
+ sha512: OpenSSL::Digest.new("SHA512").file("large.bin").digest,
103
+ )
104
+ gen.add(pre)
105
+ record.verify(data: pre, timestamp_validator: validator)
106
+ record.hash_tree_renewal(data: pre, digest_algorithm: :sha512)
107
+ ```
108
+
109
+ ### Evidence Record の内容確認
110
+
111
+ ```ruby
112
+ record.chains.each do |chain|
113
+ chain.archive_timestamps.each do |ats|
114
+ ts = ats.timestamp
115
+ puts "#{ats.digest_algorithm} #{ts.gen_time} serial=#{ts.serial_number}"
116
+ end
117
+ end
118
+
119
+ # 最後のタイムスタンプの TSA 証明書の期限を調べる
120
+ last = record.chains.last.archive_timestamps.last.timestamp
121
+ last.certificates.map(&:not_after)
122
+ ```
123
+
124
+ ### 参照
125
+
126
+ - 各クラス・メソッドの仕様は [docs/reference.md](docs/reference.md) を参照
127
+
128
+ ## 開発
129
+
130
+ ```bash
131
+ bundle install
132
+ bundle exec rake test
133
+ ```
134
+
135
+ BSI のテストデータを使うテストは、データをダウンロードしてから実行する。
136
+ テストデータは [bcgit/bc-test-data](https://github.com/bcgit/bc-test-data) の `tsp/20170316_Testdaten` (BSI TR-ESOR / ERS テストツール)から固定したコミットのものを取得し、SHA-256 で内容を確認する。
137
+
138
+ ```bash
139
+ bundle exec rake test:fetch_vectors # test/fixtures/bsi に取得する
140
+ ```
141
+
142
+ Bouncy Castle との相互運用テストには JDK が必要。Bouncy Castle は Maven Central から固定したバージョンのものを取得し、SHA-256 で内容を確認する。
143
+
144
+ ```bash
145
+ bundle exec rake test:fetch_bouncy_castle # interop/lib に取得する
146
+ bundle exec rake test:interop
147
+ ```
148
+
149
+ Docker を使う場合:
150
+
151
+ ```bash
152
+ docker compose run --rm test # Ruby 4.0
153
+ docker compose run --rm test-3.4 # Ruby 3.4
154
+ docker compose run --rm test bundle exec rake test:fetch_vectors
155
+ docker compose run --rm interop # Bouncy Castle との相互運用テスト(JDK 入りのイメージ)
156
+ ```
157
+
158
+ ## ライセンス
159
+
160
+ [MIT License](LICENSE.txt)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # Internal: reading and writing the AlgorithmIdentifiers of hash
5
+ # algorithms.
6
+ #
7
+ # Any OID is read, so that Evidence Records using other algorithms can still
8
+ # be parsed; DigestAlgorithm.from_oid raises UnsupportedAlgorithmError when
9
+ # such an algorithm is used. Parameters of SHA-2 must be absent or NULL
10
+ # (RFC 5754 §2); absent is generated.
11
+ module AlgorithmIdentifier
12
+ module_function
13
+
14
+ # node: a SEQUENCE, or with context: n, a constructed element with the
15
+ # implicit tag [n]. Returns the OID as a dotted String.
16
+ def parse(node, what, context: nil)
17
+ children = DER.children_of(node, "#{what} (AlgorithmIdentifier)", size: 1..2, context: context)
18
+ oid = DER.oid(children[0], what)
19
+ params = children[1]
20
+ if params && DigestAlgorithm::BY_OID.key?(oid) && !params.universal?(DER::NULL)
21
+ raise ParseError, "#{what}: parameters of #{DigestAlgorithm::BY_OID[oid]} must be absent or NULL"
22
+ end
23
+
24
+ oid
25
+ end
26
+
27
+ # The contents octets of an AlgorithmIdentifier (to be wrapped in a
28
+ # SEQUENCE or an implicit tag).
29
+ def encode_content(algorithm)
30
+ DER.encode_oid(algorithm.oid)
31
+ end
32
+
33
+ def encode(algorithm)
34
+ DER.sequence([encode_content(algorithm)])
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # An ArchiveTimeStamp (RFC 4998 §4.1): a time-stamp token and the reduced
5
+ # hash tree leading from a data object (or an earlier time-stamp) to the
6
+ # time-stamped root.
7
+ #
8
+ # ArchiveTimeStamp ::= SEQUENCE {
9
+ # digestAlgorithm [0] AlgorithmIdentifier OPTIONAL,
10
+ # attributes [1] Attributes OPTIONAL,
11
+ # reducedHashtree [2] SEQUENCE OF PartialHashtree OPTIONAL,
12
+ # timeStamp ContentInfo }
13
+ #
14
+ # The original DER is kept and returned by #to_der.
15
+ class ArchiveTimeStamp
16
+ # The time-stamp token (TimeStampToken).
17
+ attr_reader :timestamp
18
+ # The ReducedHashtree, or nil when the field is absent (the time-stamp
19
+ # covers the hash of a single data object directly).
20
+ attr_reader :reduced_hashtree
21
+ # The attributes field as it was read (DER, including its [1] tag), or
22
+ # nil. It is kept but not interpreted (docs/design.md 7.3).
23
+ attr_reader :attributes
24
+ # Internal: the OID of the digestAlgorithm field, or nil when absent.
25
+ attr_reader :declared_digest_algorithm_oid
26
+
27
+ class << self
28
+ def from_der(der)
29
+ from_node(DER.parse(der, "ArchiveTimeStamp"), "ArchiveTimeStamp")
30
+ end
31
+
32
+ # Internal: from an already parsed element.
33
+ def from_node(node, what)
34
+ new(node, what)
35
+ end
36
+
37
+ # Internal: encodes a new ArchiveTimeStamp. Used by the generator and
38
+ # the renewals.
39
+ def build(timestamp:, reduced_hashtree: nil, digest_algorithm: nil)
40
+ parts = []
41
+ parts << DER.context(0, AlgorithmIdentifier.encode_content(digest_algorithm)) if digest_algorithm
42
+ if reduced_hashtree
43
+ partials = reduced_hashtree.partial_hashtrees.map do |values|
44
+ DER.sequence(values.map { DER.encode_octet_string(_1.to_binary) })
45
+ end
46
+ parts << DER.context(2, partials.join)
47
+ end
48
+ parts << timestamp.to_der
49
+ from_der(DER.sequence(parts))
50
+ end
51
+
52
+ private :new
53
+ end
54
+
55
+ def initialize(node, what)
56
+ @der = node.raw
57
+ *fields, timestamp = DER.children_of(node, what, size: 1..4)
58
+ @timestamp = TimeStampToken.from_node(timestamp, "#{what} timeStamp")
59
+
60
+ previous_tag = -1
61
+ fields.each do |field|
62
+ tag = field.tag_class == :context && field.constructed? ? field.tag : nil
63
+ unless tag && tag <= 2 && tag > previous_tag
64
+ raise ParseError, "#{what}: unexpected element before timeStamp"
65
+ end
66
+
67
+ previous_tag = tag
68
+ case tag
69
+ when 0 then @declared_digest_algorithm_oid = AlgorithmIdentifier.parse(field, "#{what} digestAlgorithm", context: 0)
70
+ when 1 then @attributes = parse_attributes(field, "#{what} attributes")
71
+ when 2 then @reduced_hashtree = parse_reduced_hashtree(field, "#{what} reducedHashtree")
72
+ end
73
+ end
74
+ freeze
75
+ end
76
+
77
+ # The OID of the hash algorithm of the reduced hash tree: the
78
+ # digestAlgorithm field, or the hash algorithm of the time-stamp when the
79
+ # field is absent (RFC 4998 §4.1).
80
+ def digest_algorithm_oid
81
+ @declared_digest_algorithm_oid || @timestamp.hash_algorithm_oid
82
+ end
83
+
84
+ # The hash algorithm of the reduced hash tree, as a DigestAlgorithm.
85
+ # Raises UnsupportedAlgorithmError for other algorithms.
86
+ def digest_algorithm
87
+ DigestAlgorithm.from_oid(digest_algorithm_oid)
88
+ end
89
+
90
+ def to_der
91
+ @der
92
+ end
93
+
94
+ def ==(other)
95
+ other.is_a?(ArchiveTimeStamp) && to_der == other.to_der
96
+ end
97
+ alias eql? ==
98
+
99
+ def hash
100
+ [ArchiveTimeStamp, @der].hash
101
+ end
102
+
103
+ def inspect
104
+ shape = @reduced_hashtree ? @reduced_hashtree.partial_hashtrees.map(&:size).join("-") : "none"
105
+ "#<#{self.class.name} digest_algorithm_oid=#{digest_algorithm_oid} " \
106
+ "reduced_hashtree=#{shape} timestamp=#{@timestamp.inspect}>"
107
+ end
108
+
109
+ private
110
+
111
+ # Attributes ::= SET SIZE (1..MAX) OF Attribute
112
+ def parse_attributes(node, what)
113
+ if node.children.empty? || !node.children.all? { _1.universal?(DER::SEQUENCE) } ||
114
+ !DER.sorted?(node.children)
115
+ raise ParseError, "#{what}: invalid Attributes"
116
+ end
117
+
118
+ node.raw
119
+ end
120
+
121
+ # SEQUENCE OF PartialHashtree; PartialHashtree ::= SEQUENCE OF OCTET STRING
122
+ def parse_reduced_hashtree(node, what)
123
+ partials = node.children.each_with_index.map do |partial, i|
124
+ DER.children_of(partial, "#{what}[#{i}]").map { DER.octet_string(_1, "#{what}[#{i}]") }
125
+ end
126
+ ReducedHashtree.new(partials)
127
+ rescue ArgumentError => e
128
+ raise ParseError, "#{what}: #{e.message}"
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # An ArchiveTimeStampChain (RFC 4998 §5): ArchiveTimeStamps that use the
5
+ # same hash algorithm, each covering the time-stamp of the one before.
6
+ #
7
+ # The original DER is kept and returned by #to_der.
8
+ class Chain
9
+ attr_reader :archive_timestamps
10
+
11
+ class << self
12
+ def from_der(der)
13
+ from_node(DER.parse(der, "ArchiveTimeStampChain"), "ArchiveTimeStampChain")
14
+ end
15
+
16
+ # Internal: from an already parsed element.
17
+ def from_node(node, what)
18
+ new(node, what)
19
+ end
20
+
21
+ # Internal: encodes a new chain from ArchiveTimeStamps.
22
+ def build(archive_timestamps)
23
+ from_der(DER.sequence(archive_timestamps.map(&:to_der)))
24
+ end
25
+
26
+ private :new
27
+ end
28
+
29
+ def initialize(node, what)
30
+ @der = node.raw
31
+ @archive_timestamps = DER.children_of(node, what).each_with_index.map do |ats, i|
32
+ ArchiveTimeStamp.from_node(ats, "#{what}[#{i}]")
33
+ end.freeze
34
+ freeze
35
+ end
36
+
37
+ def to_der
38
+ @der
39
+ end
40
+
41
+ def ==(other)
42
+ other.is_a?(Chain) && to_der == other.to_der
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ [Chain, @der].hash
48
+ end
49
+
50
+ def inspect
51
+ "#<#{self.class.name} #{@archive_timestamps.inspect}>"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvidenceRecord
4
+ # Hash values of a data object computed elsewhere, given per algorithm
5
+ # (docs/design.md 3.7.1). Use it for data that is not at hand or too large
6
+ # to read into a String.
7
+ #
8
+ # EvidenceRecord::PrehashedData.new(sha256: d256, sha512: d512)
9
+ #
10
+ # The library cannot check that the hashes were computed from the same data.
11
+ class PrehashedData
12
+ def initialize(**hashes)
13
+ raise ArgumentError, "at least one hash must be given" if hashes.empty?
14
+
15
+ @hashes = hashes.to_h do |name, value|
16
+ algorithm = DigestAlgorithm.fetch(name)
17
+ bytes = Digest.bytes_of(value, "#{name} hash")
18
+ unless bytes.bytesize == algorithm.digest_size
19
+ raise ArgumentError,
20
+ "#{name} hash must be #{algorithm.digest_size} bytes, got #{bytes.bytesize}"
21
+ end
22
+ [algorithm, bytes]
23
+ end.freeze
24
+ freeze
25
+ end
26
+
27
+ def algorithms
28
+ @hashes.keys
29
+ end
30
+
31
+ # The hash for algorithm (a Symbol or DigestAlgorithm) as a Digest, or nil.
32
+ def [](algorithm)
33
+ bytes = @hashes[DigestAlgorithm.fetch(algorithm)]
34
+ bytes && Digest.new(bytes)
35
+ end
36
+
37
+ def inspect
38
+ "#<#{self.class.name} #{@hashes.map { |a, b| "#{a}=#{b.unpack1('H*')}" }.join(' ')}>"
39
+ end
40
+ end
41
+
42
+ # A data object group: several data objects covered by one Evidence Record
43
+ # (RFC 4998 §4.2). Members are Strings or PrehashedData; groups cannot be
44
+ # nested.
45
+ class DataGroup
46
+ attr_reader :members
47
+
48
+ def initialize(members)
49
+ raise ArgumentError, "members must be an Array, got #{members.class}" unless members.is_a?(Array)
50
+ raise ArgumentError, "a data group needs at least one member" if members.empty?
51
+
52
+ members.each_with_index do |m, i|
53
+ raise ArgumentError, "data groups cannot be nested (member #{i})" if m.is_a?(DataGroup)
54
+
55
+ DataSource.check!(m, "member #{i}")
56
+ end
57
+ @members = members.dup.freeze
58
+ freeze
59
+ end
60
+
61
+ def size
62
+ @members.size
63
+ end
64
+ end
65
+
66
+ # Internal: turns the values accepted as data (String, PrehashedData,
67
+ # DataGroup) into objects that compute hash values, caching them per
68
+ # algorithm.
69
+ module DataSource
70
+ module_function
71
+
72
+ def check!(value, name = "data")
73
+ return if value.is_a?(String) || value.is_a?(PrehashedData)
74
+
75
+ raise ArgumentError,
76
+ "#{name} must be a String, an EvidenceRecord::PrehashedData " \
77
+ "or an EvidenceRecord::DataGroup, got #{value.class}"
78
+ end
79
+
80
+ def wrap(value)
81
+ case value
82
+ when Single, Group then value
83
+ when DataGroup then Group.new(value.members.map { Single.new(_1) })
84
+ else
85
+ check!(value)
86
+ Single.new(value)
87
+ end
88
+ end
89
+
90
+ # One data object.
91
+ class Single
92
+ def initialize(value)
93
+ @value = value
94
+ @cache = {}
95
+ end
96
+
97
+ def group?
98
+ false
99
+ end
100
+
101
+ # The hash of the data object, as a BINARY String.
102
+ def digest(algorithm)
103
+ algorithm = DigestAlgorithm.fetch(algorithm)
104
+ @cache[algorithm] ||= compute(algorithm)
105
+ end
106
+
107
+ # [hash]; the list that becomes the first partial hash tree.
108
+ def member_digests(algorithm)
109
+ [digest(algorithm)]
110
+ end
111
+
112
+ private
113
+
114
+ def compute(algorithm)
115
+ case @value
116
+ when PrehashedData
117
+ d = @value[algorithm] or
118
+ raise ArgumentError, "PrehashedData has no #{algorithm} hash, which is required here"
119
+ d.to_binary
120
+ else
121
+ algorithm.digest(@value).freeze
122
+ end
123
+ end
124
+ end
125
+
126
+ # A data object group.
127
+ class Group
128
+ attr_reader :members
129
+
130
+ def initialize(members)
131
+ @members = members
132
+ end
133
+
134
+ def group?
135
+ true
136
+ end
137
+
138
+ def member_digests(algorithm)
139
+ @members.map { _1.digest(algorithm) }
140
+ end
141
+ end
142
+ end
143
+ end