easy_code_sign 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/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +331 -0
- data/Rakefile +16 -0
- data/exe/easysign +7 -0
- data/lib/easy_code_sign/cli.rb +428 -0
- data/lib/easy_code_sign/configuration.rb +102 -0
- data/lib/easy_code_sign/deferred_signing_request.rb +104 -0
- data/lib/easy_code_sign/errors.rb +113 -0
- data/lib/easy_code_sign/pdf/appearance_builder.rb +104 -0
- data/lib/easy_code_sign/pdf/timestamp_handler.rb +31 -0
- data/lib/easy_code_sign/providers/base.rb +126 -0
- data/lib/easy_code_sign/providers/pkcs11_base.rb +197 -0
- data/lib/easy_code_sign/providers/safenet.rb +109 -0
- data/lib/easy_code_sign/signable/base.rb +98 -0
- data/lib/easy_code_sign/signable/gem_file.rb +224 -0
- data/lib/easy_code_sign/signable/pdf_file.rb +486 -0
- data/lib/easy_code_sign/signable/zip_file.rb +226 -0
- data/lib/easy_code_sign/signer.rb +254 -0
- data/lib/easy_code_sign/timestamp/client.rb +184 -0
- data/lib/easy_code_sign/timestamp/request.rb +114 -0
- data/lib/easy_code_sign/timestamp/response.rb +246 -0
- data/lib/easy_code_sign/timestamp/verifier.rb +227 -0
- data/lib/easy_code_sign/verification/certificate_chain.rb +298 -0
- data/lib/easy_code_sign/verification/result.rb +222 -0
- data/lib/easy_code_sign/verification/signature_checker.rb +196 -0
- data/lib/easy_code_sign/verification/trust_store.rb +140 -0
- data/lib/easy_code_sign/verifier.rb +426 -0
- data/lib/easy_code_sign/version.rb +5 -0
- data/lib/easy_code_sign.rb +183 -0
- data/plugin/.gitignore +21 -0
- data/plugin/Gemfile +24 -0
- data/plugin/Gemfile.lock +134 -0
- data/plugin/README.md +248 -0
- data/plugin/Rakefile +121 -0
- data/plugin/docs/API_REFERENCE.md +366 -0
- data/plugin/docs/DEVELOPMENT.md +522 -0
- data/plugin/docs/INSTALLATION.md +204 -0
- data/plugin/native_host/build/Rakefile +90 -0
- data/plugin/native_host/install/com.easysign.host.json +9 -0
- data/plugin/native_host/install/install_chrome.sh +81 -0
- data/plugin/native_host/install/install_firefox.sh +81 -0
- data/plugin/native_host/src/easy_sign_host.rb +158 -0
- data/plugin/native_host/src/protocol.rb +101 -0
- data/plugin/native_host/src/signing_service.rb +167 -0
- data/plugin/native_host/test/native_host_test.rb +113 -0
- data/plugin/src/easy_sign/background.rb +323 -0
- data/plugin/src/easy_sign/content.rb +74 -0
- data/plugin/src/easy_sign/inject.rb +239 -0
- data/plugin/src/easy_sign/messaging.rb +109 -0
- data/plugin/src/easy_sign/popup.rb +200 -0
- data/plugin/templates/manifest.json +58 -0
- data/plugin/templates/popup.css +223 -0
- data/plugin/templates/popup.html +59 -0
- data/sig/easy_code_sign.rbs +4 -0
- data/test/easy_code_sign_test.rb +122 -0
- data/test/pdf_signable_test.rb +569 -0
- data/test/signable_test.rb +334 -0
- data/test/test_helper.rb +18 -0
- data/test/timestamp_test.rb +163 -0
- data/test/verification_test.rb +350 -0
- metadata +219 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "hexapdf"
|
|
6
|
+
|
|
7
|
+
class PdfFileSignableTest < EasyCodeSignTest
|
|
8
|
+
def setup
|
|
9
|
+
@temp_pdf = Tempfile.new(["test", ".pdf"])
|
|
10
|
+
create_test_pdf(@temp_pdf.path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def teardown
|
|
14
|
+
@temp_pdf.close
|
|
15
|
+
@temp_pdf.unlink
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_validates_pdf_extension
|
|
19
|
+
txt_file = Tempfile.new(["test", ".txt"])
|
|
20
|
+
txt_file.write("content")
|
|
21
|
+
txt_file.close
|
|
22
|
+
|
|
23
|
+
assert_raises(EasyCodeSign::InvalidPdfError) do
|
|
24
|
+
EasyCodeSign::Signable::PdfFile.new(txt_file.path)
|
|
25
|
+
end
|
|
26
|
+
ensure
|
|
27
|
+
txt_file.unlink
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_validates_pdf_header
|
|
31
|
+
fake_pdf = Tempfile.new(["fake", ".pdf"])
|
|
32
|
+
fake_pdf.write("Not a PDF file")
|
|
33
|
+
fake_pdf.close
|
|
34
|
+
|
|
35
|
+
assert_raises(EasyCodeSign::InvalidPdfError) do
|
|
36
|
+
EasyCodeSign::Signable::PdfFile.new(fake_pdf.path)
|
|
37
|
+
end
|
|
38
|
+
ensure
|
|
39
|
+
fake_pdf.unlink
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_accepts_valid_pdf_file
|
|
43
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
44
|
+
assert_instance_of EasyCodeSign::Signable::PdfFile, signable
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_signed_returns_false_for_unsigned_pdf
|
|
48
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
49
|
+
refute signable.signed?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_extract_signature_returns_nil_for_unsigned
|
|
53
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
54
|
+
assert_nil signable.extract_signature
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_prepare_for_signing_succeeds
|
|
58
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
59
|
+
signable.prepare_for_signing
|
|
60
|
+
|
|
61
|
+
assert_equal "PDF_SIGNING_PLACEHOLDER", signable.content_to_sign
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_default_signature_config
|
|
65
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
66
|
+
|
|
67
|
+
refute signable.signature_config[:visible]
|
|
68
|
+
assert_equal 1, signable.signature_config[:page]
|
|
69
|
+
assert_equal :bottom_right, signable.signature_config[:position]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_visible_signature_option
|
|
73
|
+
signable = EasyCodeSign::Signable::PdfFile.new(
|
|
74
|
+
@temp_pdf.path,
|
|
75
|
+
visible_signature: true,
|
|
76
|
+
signature_position: :top_left
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert signable.signature_config[:visible]
|
|
80
|
+
assert_equal :top_left, signable.signature_config[:position]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_signature_reason_and_location
|
|
84
|
+
signable = EasyCodeSign::Signable::PdfFile.new(
|
|
85
|
+
@temp_pdf.path,
|
|
86
|
+
signature_reason: "Approved",
|
|
87
|
+
signature_location: "New York"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert_equal "Approved", signable.signature_config[:reason]
|
|
91
|
+
assert_equal "New York", signable.signature_config[:location]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_signature_page_option
|
|
95
|
+
signable = EasyCodeSign::Signable::PdfFile.new(
|
|
96
|
+
@temp_pdf.path,
|
|
97
|
+
signature_page: 2
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert_equal 2, signable.signature_config[:page]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_signable_for_returns_pdf_file
|
|
104
|
+
signable = EasyCodeSign.signable_for(@temp_pdf.path)
|
|
105
|
+
assert_instance_of EasyCodeSign::Signable::PdfFile, signable
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def create_test_pdf(path)
|
|
111
|
+
doc = HexaPDF::Document.new
|
|
112
|
+
page = doc.pages.add
|
|
113
|
+
page.canvas.font("Helvetica", size: 12)
|
|
114
|
+
page.canvas.text("Test PDF Document", at: [100, 700])
|
|
115
|
+
doc.write(path)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class PdfVerificationTest < EasyCodeSignTest
|
|
120
|
+
def setup
|
|
121
|
+
@temp_pdf = Tempfile.new(["test", ".pdf"])
|
|
122
|
+
create_test_pdf(@temp_pdf.path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def teardown
|
|
126
|
+
@temp_pdf.close
|
|
127
|
+
@temp_pdf.unlink
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_verify_unsigned_pdf_returns_error
|
|
131
|
+
verifier = EasyCodeSign::Verifier.new
|
|
132
|
+
result = verifier.verify(@temp_pdf.path)
|
|
133
|
+
|
|
134
|
+
refute result.valid?
|
|
135
|
+
assert_any_match(result.errors, /not signed/i)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def test_verifier_creates_pdf_signable
|
|
139
|
+
verifier = EasyCodeSign::Verifier.new
|
|
140
|
+
result = verifier.verify(@temp_pdf.path)
|
|
141
|
+
|
|
142
|
+
assert_equal :pdffile, result.file_type
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def create_test_pdf(path)
|
|
148
|
+
doc = HexaPDF::Document.new
|
|
149
|
+
page = doc.pages.add
|
|
150
|
+
page.canvas.font("Helvetica", size: 12)
|
|
151
|
+
page.canvas.text("Test PDF for verification", at: [100, 700])
|
|
152
|
+
doc.write(path)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def assert_any_match(array, pattern)
|
|
156
|
+
assert array.any? { |item| item.match?(pattern) },
|
|
157
|
+
"Expected at least one item in #{array.inspect} to match #{pattern.inspect}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class PdfDeferredSigningTest < EasyCodeSignTest
|
|
162
|
+
def setup
|
|
163
|
+
super
|
|
164
|
+
@temp_pdf = Tempfile.new(["deferred_test", ".pdf"])
|
|
165
|
+
create_test_pdf(@temp_pdf.path)
|
|
166
|
+
@key = OpenSSL::PKey::RSA.new(2048)
|
|
167
|
+
@cert = create_test_cert(@key)
|
|
168
|
+
@chain = [@cert]
|
|
169
|
+
@cleanup_files = []
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def teardown
|
|
173
|
+
@temp_pdf.close
|
|
174
|
+
@temp_pdf.unlink
|
|
175
|
+
@cleanup_files.each { |f| File.delete(f) if File.exist?(f) }
|
|
176
|
+
super
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_prepare_deferred_returns_request
|
|
180
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
181
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
182
|
+
track_prepared(request)
|
|
183
|
+
|
|
184
|
+
assert_instance_of EasyCodeSign::DeferredSigningRequest, request
|
|
185
|
+
assert_instance_of String, request.digest
|
|
186
|
+
assert_equal :sha256, request.digest_algorithm
|
|
187
|
+
assert File.exist?(request.prepared_pdf_path)
|
|
188
|
+
assert_instance_of Array, request.byte_range
|
|
189
|
+
assert_equal 4, request.byte_range.size
|
|
190
|
+
assert_equal @cert.to_pem, request.certificate.to_pem
|
|
191
|
+
assert_instance_of Time, request.signing_time
|
|
192
|
+
assert_instance_of Time, request.created_at
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def test_prepare_deferred_creates_valid_pdf
|
|
196
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
197
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
198
|
+
track_prepared(request)
|
|
199
|
+
|
|
200
|
+
assert File.exist?(request.prepared_pdf_path)
|
|
201
|
+
header = File.open(request.prepared_pdf_path, "rb") { |f| f.read(5) }
|
|
202
|
+
assert_equal "%PDF-", header
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def test_prepare_deferred_digest_size
|
|
206
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
207
|
+
request = signable.prepare_deferred(@cert, @chain, digest_algorithm: "sha256")
|
|
208
|
+
track_prepared(request)
|
|
209
|
+
|
|
210
|
+
assert_equal 32, request.digest.bytesize
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def test_prepare_deferred_sha512_digest_size
|
|
214
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
215
|
+
request = signable.prepare_deferred(@cert, @chain, digest_algorithm: "sha512")
|
|
216
|
+
track_prepared(request)
|
|
217
|
+
|
|
218
|
+
assert_equal 64, request.digest.bytesize
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def test_deferred_request_serialization_roundtrip
|
|
222
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
223
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
224
|
+
track_prepared(request)
|
|
225
|
+
|
|
226
|
+
hash = request.to_h
|
|
227
|
+
restored = EasyCodeSign::DeferredSigningRequest.from_h(hash)
|
|
228
|
+
|
|
229
|
+
assert_equal request.digest, restored.digest
|
|
230
|
+
assert_equal request.digest_algorithm, restored.digest_algorithm
|
|
231
|
+
assert_equal request.prepared_pdf_path, restored.prepared_pdf_path
|
|
232
|
+
assert_equal request.byte_range, restored.byte_range
|
|
233
|
+
assert_equal request.certificate.to_pem, restored.certificate.to_pem
|
|
234
|
+
assert_equal request.certificate_chain.map(&:to_pem), restored.certificate_chain.map(&:to_pem)
|
|
235
|
+
assert_equal request.estimated_size, restored.estimated_size
|
|
236
|
+
assert_equal request.signing_time.to_i, restored.signing_time.to_i
|
|
237
|
+
assert_equal request.created_at.to_i, restored.created_at.to_i
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def test_deferred_request_digest_hex_and_base64
|
|
241
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
242
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
243
|
+
track_prepared(request)
|
|
244
|
+
|
|
245
|
+
assert_equal request.digest.unpack1("H*"), request.digest_hex
|
|
246
|
+
assert_equal Base64.strict_encode64(request.digest), request.digest_base64
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def test_finalize_deferred_produces_signed_pdf
|
|
250
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
251
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
252
|
+
track_prepared(request)
|
|
253
|
+
|
|
254
|
+
# Sign the digest with our test key (simulating external signer)
|
|
255
|
+
raw_signature = @key.sign_raw("sha256", request.digest)
|
|
256
|
+
|
|
257
|
+
finalizer = EasyCodeSign::Signable::PdfFile.new(request.prepared_pdf_path)
|
|
258
|
+
signed_path = finalizer.finalize_deferred(request, raw_signature)
|
|
259
|
+
|
|
260
|
+
assert File.exist?(signed_path)
|
|
261
|
+
|
|
262
|
+
# Verify the signed PDF has a signature
|
|
263
|
+
verifier_signable = EasyCodeSign::Signable::PdfFile.new(signed_path)
|
|
264
|
+
sig = verifier_signable.extract_signature
|
|
265
|
+
refute_nil sig, "Expected signed PDF to have an extractable signature"
|
|
266
|
+
refute_nil sig[:contents]
|
|
267
|
+
refute_nil sig[:byte_range]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def test_signed_attributes_data_present
|
|
271
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
272
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
273
|
+
track_prepared(request)
|
|
274
|
+
|
|
275
|
+
refute_nil request.signed_attributes_data
|
|
276
|
+
refute_empty request.signed_attributes_data
|
|
277
|
+
refute_nil request.signed_attributes_base64
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def test_signed_attributes_data_hash_matches_digest
|
|
281
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
282
|
+
request = signable.prepare_deferred(@cert, @chain, digest_algorithm: "sha256")
|
|
283
|
+
track_prepared(request)
|
|
284
|
+
|
|
285
|
+
computed_hash = OpenSSL::Digest::SHA256.digest(request.signed_attributes_data)
|
|
286
|
+
assert_equal request.digest, computed_hash,
|
|
287
|
+
"SHA256(signed_attributes_data) must equal the captured digest"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def test_webcrypto_style_signing_produces_valid_pdf
|
|
291
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
292
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
293
|
+
track_prepared(request)
|
|
294
|
+
|
|
295
|
+
# Simulate WebCrypto: hash-and-sign in one step (like crypto.subtle.sign)
|
|
296
|
+
raw_signature = @key.sign("SHA256", request.signed_attributes_data)
|
|
297
|
+
|
|
298
|
+
finalizer = EasyCodeSign::Signable::PdfFile.new(request.prepared_pdf_path)
|
|
299
|
+
signed_path = finalizer.finalize_deferred(request, raw_signature)
|
|
300
|
+
|
|
301
|
+
assert File.exist?(signed_path)
|
|
302
|
+
|
|
303
|
+
verifier_signable = EasyCodeSign::Signable::PdfFile.new(signed_path)
|
|
304
|
+
sig = verifier_signable.extract_signature
|
|
305
|
+
refute_nil sig, "Expected signed PDF to have an extractable signature"
|
|
306
|
+
refute_nil sig[:contents]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def test_signed_attributes_data_serialization_roundtrip
|
|
310
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
311
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
312
|
+
track_prepared(request)
|
|
313
|
+
|
|
314
|
+
hash = request.to_h
|
|
315
|
+
assert hash.key?("signed_attributes_data"), "to_h should include signed_attributes_data"
|
|
316
|
+
|
|
317
|
+
restored = EasyCodeSign::DeferredSigningRequest.from_h(hash)
|
|
318
|
+
assert_equal request.signed_attributes_data, restored.signed_attributes_data
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def test_finalize_deferred_raises_on_missing_pdf
|
|
322
|
+
request = EasyCodeSign::DeferredSigningRequest.new(
|
|
323
|
+
digest: "\x00" * 32,
|
|
324
|
+
digest_algorithm: :sha256,
|
|
325
|
+
prepared_pdf_path: "/nonexistent/path/missing.pdf",
|
|
326
|
+
byte_range: [0, 100, 200, 100],
|
|
327
|
+
certificate: @cert,
|
|
328
|
+
certificate_chain: @chain,
|
|
329
|
+
estimated_size: 8192,
|
|
330
|
+
signing_time: Time.now
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
334
|
+
|
|
335
|
+
assert_raises(EasyCodeSign::DeferredSigningError) do
|
|
336
|
+
signable.finalize_deferred(request, "fake_sig")
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def test_finalize_deferred_raises_on_oversized_signature
|
|
341
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
342
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
343
|
+
track_prepared(request)
|
|
344
|
+
|
|
345
|
+
# Create a signature that's way too large for the reserved space
|
|
346
|
+
oversized_signature = "\xFF" * (request.estimated_size * 3)
|
|
347
|
+
|
|
348
|
+
finalizer = EasyCodeSign::Signable::PdfFile.new(request.prepared_pdf_path)
|
|
349
|
+
|
|
350
|
+
assert_raises(EasyCodeSign::DeferredSigningError) do
|
|
351
|
+
finalizer.finalize_deferred(request, oversized_signature)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def test_finalize_deferred_with_timestamp_embeds_token
|
|
356
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
357
|
+
request = signable.prepare_deferred(@cert, @chain, timestamp_size: 4096)
|
|
358
|
+
track_prepared(request)
|
|
359
|
+
|
|
360
|
+
raw_signature = @key.sign_raw("sha256", request.digest)
|
|
361
|
+
mock_token = create_mock_timestamp_token
|
|
362
|
+
|
|
363
|
+
finalizer = EasyCodeSign::Signable::PdfFile.new(request.prepared_pdf_path)
|
|
364
|
+
signed_path = finalizer.finalize_deferred(request, raw_signature, timestamp_token: mock_token)
|
|
365
|
+
|
|
366
|
+
assert File.exist?(signed_path)
|
|
367
|
+
|
|
368
|
+
# Parse the CMS from the signed PDF and check for timestamp unsigned attribute
|
|
369
|
+
verifier_signable = EasyCodeSign::Signable::PdfFile.new(signed_path)
|
|
370
|
+
sig = verifier_signable.extract_signature
|
|
371
|
+
refute_nil sig, "Expected signed PDF to have a signature"
|
|
372
|
+
|
|
373
|
+
# Decode the CMS DER (trimming zero-padding) and look for id-aa-timeStampToken OID
|
|
374
|
+
cms_asn1 = decode_cms_from_contents(sig[:contents])
|
|
375
|
+
assert_timestamp_attribute_present(cms_asn1)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def test_finalize_deferred_without_timestamp_unchanged
|
|
379
|
+
signable = EasyCodeSign::Signable::PdfFile.new(@temp_pdf.path)
|
|
380
|
+
request = signable.prepare_deferred(@cert, @chain)
|
|
381
|
+
track_prepared(request)
|
|
382
|
+
|
|
383
|
+
raw_signature = @key.sign_raw("sha256", request.digest)
|
|
384
|
+
|
|
385
|
+
finalizer = EasyCodeSign::Signable::PdfFile.new(request.prepared_pdf_path)
|
|
386
|
+
signed_path = finalizer.finalize_deferred(request, raw_signature)
|
|
387
|
+
|
|
388
|
+
assert File.exist?(signed_path)
|
|
389
|
+
|
|
390
|
+
verifier_signable = EasyCodeSign::Signable::PdfFile.new(signed_path)
|
|
391
|
+
sig = verifier_signable.extract_signature
|
|
392
|
+
refute_nil sig
|
|
393
|
+
|
|
394
|
+
# Verify no timestamp unsigned attribute is present
|
|
395
|
+
cms_asn1 = decode_cms_from_contents(sig[:contents])
|
|
396
|
+
refute_timestamp_attribute_present(cms_asn1)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
def track_prepared(request)
|
|
402
|
+
@cleanup_files << request.prepared_pdf_path
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def create_test_pdf(path)
|
|
406
|
+
doc = HexaPDF::Document.new
|
|
407
|
+
page = doc.pages.add
|
|
408
|
+
page.canvas.font("Helvetica", size: 12)
|
|
409
|
+
page.canvas.text("Deferred signing test document", at: [100, 700])
|
|
410
|
+
doc.write(path)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def create_test_cert(key)
|
|
414
|
+
cert = OpenSSL::X509::Certificate.new
|
|
415
|
+
cert.version = 2
|
|
416
|
+
cert.serial = 1
|
|
417
|
+
cert.subject = OpenSSL::X509::Name.parse("/CN=Test Deferred Signer")
|
|
418
|
+
cert.issuer = cert.subject
|
|
419
|
+
cert.public_key = key.public_key
|
|
420
|
+
cert.not_before = Time.now
|
|
421
|
+
cert.not_after = Time.now + 86_400
|
|
422
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
423
|
+
cert
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def create_mock_timestamp_token
|
|
427
|
+
# Build a minimal self-signed PKCS#7 structure as mock token DER
|
|
428
|
+
tsa_key = OpenSSL::PKey::RSA.new(2048)
|
|
429
|
+
tsa_cert = OpenSSL::X509::Certificate.new
|
|
430
|
+
tsa_cert.version = 2
|
|
431
|
+
tsa_cert.serial = 99
|
|
432
|
+
tsa_cert.subject = OpenSSL::X509::Name.parse("/CN=Mock TSA")
|
|
433
|
+
tsa_cert.issuer = tsa_cert.subject
|
|
434
|
+
tsa_cert.public_key = tsa_key.public_key
|
|
435
|
+
tsa_cert.not_before = Time.now
|
|
436
|
+
tsa_cert.not_after = Time.now + 86_400
|
|
437
|
+
tsa_cert.sign(tsa_key, OpenSSL::Digest.new("SHA256"))
|
|
438
|
+
|
|
439
|
+
pkcs7 = OpenSSL::PKCS7.sign(tsa_cert, tsa_key, "mock timestamp data",
|
|
440
|
+
[], OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
|
|
441
|
+
|
|
442
|
+
EasyCodeSign::Timestamp::TimestampToken.new(
|
|
443
|
+
token_der: pkcs7.to_der,
|
|
444
|
+
timestamp: Time.now,
|
|
445
|
+
serial_number: 99,
|
|
446
|
+
policy_oid: "1.2.3.4",
|
|
447
|
+
tsa_url: "http://test.tsa"
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Decode CMS DER from PDF /Contents (which may have trailing zero-padding)
|
|
452
|
+
def decode_cms_from_contents(contents)
|
|
453
|
+
# The PDF reserves a fixed-size space; trailing zeros must be stripped.
|
|
454
|
+
# Parse just the first valid DER structure using decode_all which ignores trailing data.
|
|
455
|
+
OpenSSL::ASN1.decode(contents.sub(/\x00+\z/, ""))
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Recursively search the ASN.1 tree for the id-aa-timeStampToken OID
|
|
459
|
+
TIMESTAMP_TOKEN_OID = "1.2.840.113549.1.9.16.2.14"
|
|
460
|
+
|
|
461
|
+
def find_oid_in_asn1(asn1, target_oid)
|
|
462
|
+
if asn1.is_a?(OpenSSL::ASN1::ObjectId) && asn1.oid == target_oid
|
|
463
|
+
return true
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
if asn1.respond_to?(:value) && asn1.value.is_a?(Array)
|
|
467
|
+
asn1.value.any? { |child| find_oid_in_asn1(child, target_oid) }
|
|
468
|
+
else
|
|
469
|
+
false
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def assert_timestamp_attribute_present(cms_asn1)
|
|
474
|
+
assert find_oid_in_asn1(cms_asn1, TIMESTAMP_TOKEN_OID),
|
|
475
|
+
"Expected CMS to contain id-aa-timeStampToken unsigned attribute"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def refute_timestamp_attribute_present(cms_asn1)
|
|
479
|
+
refute find_oid_in_asn1(cms_asn1, TIMESTAMP_TOKEN_OID),
|
|
480
|
+
"Expected CMS NOT to contain id-aa-timeStampToken unsigned attribute"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
class PdfTimestampHandlerTest < Minitest::Test
|
|
485
|
+
def test_sign_returns_token_der_from_eager_token
|
|
486
|
+
mock_token = Struct.new(:token_der).new("mock_der_bytes")
|
|
487
|
+
handler = EasyCodeSign::Pdf::TimestampHandler.new(mock_token)
|
|
488
|
+
|
|
489
|
+
result = handler.sign(StringIO.new, [0, 0, 0, 0])
|
|
490
|
+
assert_equal "mock_der_bytes", result
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def test_sign_returns_token_der_from_lazy_proc
|
|
494
|
+
mock_token = Struct.new(:token_der).new("lazy_der_bytes")
|
|
495
|
+
handler = EasyCodeSign::Pdf::TimestampHandler.new(-> { mock_token })
|
|
496
|
+
|
|
497
|
+
result = handler.sign(StringIO.new, [0, 0, 0, 0])
|
|
498
|
+
assert_equal "lazy_der_bytes", result
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def test_sign_returns_nil_when_token_is_nil
|
|
502
|
+
handler = EasyCodeSign::Pdf::TimestampHandler.new(nil)
|
|
503
|
+
|
|
504
|
+
result = handler.sign(StringIO.new, [0, 0, 0, 0])
|
|
505
|
+
assert_nil result
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def test_sign_returns_nil_when_lazy_proc_returns_nil
|
|
509
|
+
handler = EasyCodeSign::Pdf::TimestampHandler.new(-> { nil })
|
|
510
|
+
|
|
511
|
+
result = handler.sign(StringIO.new, [0, 0, 0, 0])
|
|
512
|
+
assert_nil result
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
class ExternalSigningCallbackTest < Minitest::Test
|
|
517
|
+
def test_callback_receives_data_and_returns_signature
|
|
518
|
+
expected_signature = "mock_signature"
|
|
519
|
+
callback = EasyCodeSign::Signable::ExternalSigningCallback.new(->(data) { expected_signature })
|
|
520
|
+
|
|
521
|
+
result = callback.sign("test data", "SHA256")
|
|
522
|
+
assert_equal expected_signature, result
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def test_callback_is_private
|
|
526
|
+
callback = EasyCodeSign::Signable::ExternalSigningCallback.new(->(_) { "sig" })
|
|
527
|
+
assert callback.private?
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
class ExternalSigningProxyTest < Minitest::Test
|
|
532
|
+
def setup
|
|
533
|
+
@key = OpenSSL::PKey::RSA.new(2048)
|
|
534
|
+
@cert = create_test_cert(@key)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def test_proxy_returns_precomputed_signature
|
|
538
|
+
signature = "precomputed_signature"
|
|
539
|
+
proxy = EasyCodeSign::Signable::ExternalSigningProxy.new(signature, @cert, [@cert])
|
|
540
|
+
|
|
541
|
+
result = proxy.sign("any data", "SHA256")
|
|
542
|
+
assert_equal signature, result
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def test_proxy_has_certificate
|
|
546
|
+
proxy = EasyCodeSign::Signable::ExternalSigningProxy.new("sig", @cert, [@cert])
|
|
547
|
+
assert_equal @cert, proxy.certificate
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def test_proxy_is_private
|
|
551
|
+
proxy = EasyCodeSign::Signable::ExternalSigningProxy.new("sig", @cert, [@cert])
|
|
552
|
+
assert proxy.private?
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
private
|
|
556
|
+
|
|
557
|
+
def create_test_cert(key)
|
|
558
|
+
cert = OpenSSL::X509::Certificate.new
|
|
559
|
+
cert.version = 2
|
|
560
|
+
cert.serial = 1
|
|
561
|
+
cert.subject = OpenSSL::X509::Name.parse("/CN=Test")
|
|
562
|
+
cert.issuer = cert.subject
|
|
563
|
+
cert.public_key = key.public_key
|
|
564
|
+
cert.not_before = Time.now
|
|
565
|
+
cert.not_after = Time.now + 86_400
|
|
566
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
567
|
+
cert
|
|
568
|
+
end
|
|
569
|
+
end
|