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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +331 -0
  5. data/Rakefile +16 -0
  6. data/exe/easysign +7 -0
  7. data/lib/easy_code_sign/cli.rb +428 -0
  8. data/lib/easy_code_sign/configuration.rb +102 -0
  9. data/lib/easy_code_sign/deferred_signing_request.rb +104 -0
  10. data/lib/easy_code_sign/errors.rb +113 -0
  11. data/lib/easy_code_sign/pdf/appearance_builder.rb +104 -0
  12. data/lib/easy_code_sign/pdf/timestamp_handler.rb +31 -0
  13. data/lib/easy_code_sign/providers/base.rb +126 -0
  14. data/lib/easy_code_sign/providers/pkcs11_base.rb +197 -0
  15. data/lib/easy_code_sign/providers/safenet.rb +109 -0
  16. data/lib/easy_code_sign/signable/base.rb +98 -0
  17. data/lib/easy_code_sign/signable/gem_file.rb +224 -0
  18. data/lib/easy_code_sign/signable/pdf_file.rb +486 -0
  19. data/lib/easy_code_sign/signable/zip_file.rb +226 -0
  20. data/lib/easy_code_sign/signer.rb +254 -0
  21. data/lib/easy_code_sign/timestamp/client.rb +184 -0
  22. data/lib/easy_code_sign/timestamp/request.rb +114 -0
  23. data/lib/easy_code_sign/timestamp/response.rb +246 -0
  24. data/lib/easy_code_sign/timestamp/verifier.rb +227 -0
  25. data/lib/easy_code_sign/verification/certificate_chain.rb +298 -0
  26. data/lib/easy_code_sign/verification/result.rb +222 -0
  27. data/lib/easy_code_sign/verification/signature_checker.rb +196 -0
  28. data/lib/easy_code_sign/verification/trust_store.rb +140 -0
  29. data/lib/easy_code_sign/verifier.rb +426 -0
  30. data/lib/easy_code_sign/version.rb +5 -0
  31. data/lib/easy_code_sign.rb +183 -0
  32. data/plugin/.gitignore +21 -0
  33. data/plugin/Gemfile +24 -0
  34. data/plugin/Gemfile.lock +134 -0
  35. data/plugin/README.md +248 -0
  36. data/plugin/Rakefile +121 -0
  37. data/plugin/docs/API_REFERENCE.md +366 -0
  38. data/plugin/docs/DEVELOPMENT.md +522 -0
  39. data/plugin/docs/INSTALLATION.md +204 -0
  40. data/plugin/native_host/build/Rakefile +90 -0
  41. data/plugin/native_host/install/com.easysign.host.json +9 -0
  42. data/plugin/native_host/install/install_chrome.sh +81 -0
  43. data/plugin/native_host/install/install_firefox.sh +81 -0
  44. data/plugin/native_host/src/easy_sign_host.rb +158 -0
  45. data/plugin/native_host/src/protocol.rb +101 -0
  46. data/plugin/native_host/src/signing_service.rb +167 -0
  47. data/plugin/native_host/test/native_host_test.rb +113 -0
  48. data/plugin/src/easy_sign/background.rb +323 -0
  49. data/plugin/src/easy_sign/content.rb +74 -0
  50. data/plugin/src/easy_sign/inject.rb +239 -0
  51. data/plugin/src/easy_sign/messaging.rb +109 -0
  52. data/plugin/src/easy_sign/popup.rb +200 -0
  53. data/plugin/templates/manifest.json +58 -0
  54. data/plugin/templates/popup.css +223 -0
  55. data/plugin/templates/popup.html +59 -0
  56. data/sig/easy_code_sign.rbs +4 -0
  57. data/test/easy_code_sign_test.rb +122 -0
  58. data/test/pdf_signable_test.rb +569 -0
  59. data/test/signable_test.rb +334 -0
  60. data/test/test_helper.rb +18 -0
  61. data/test/timestamp_test.rb +163 -0
  62. data/test/verification_test.rb +350 -0
  63. 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