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,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+
5
+ module EasyCodeSign
6
+ module Signable
7
+ # Handler for signing PDF files
8
+ #
9
+ # PDF signatures use a ByteRange approach where specific byte ranges are signed,
10
+ # excluding the signature field itself. This allows incremental updates.
11
+ #
12
+ # @example Sign a PDF
13
+ # pdf = PdfFile.new("document.pdf")
14
+ # pdf.prepare_for_signing
15
+ # content = pdf.content_to_sign
16
+ # # ... sign content with HSM ...
17
+ # pdf.apply_signature(signature, certificate_chain)
18
+ #
19
+ class PdfFile < Base
20
+ SUPPORTED_EXTENSIONS = %w[.pdf].freeze
21
+
22
+ # Signature appearance configuration
23
+ attr_reader :signature_config
24
+
25
+ def initialize(file_path, **options)
26
+ super
27
+ validate_pdf!
28
+ @signature_config = build_signature_config(options)
29
+ @document = nil
30
+ @signature_field = nil
31
+ @prepared_data = nil
32
+ end
33
+
34
+ # Prepare PDF for signing by creating signature field and calculating ByteRange
35
+ # @return [void]
36
+ def prepare_for_signing
37
+ @document = HexaPDF::Document.open(file_path)
38
+
39
+ # Create signature field
40
+ @signature_field = create_signature_field
41
+
42
+ # Set up the signing handler that will be called by HexaPDF
43
+ @prepared_data = {
44
+ document: @document,
45
+ signature_field: @signature_field
46
+ }
47
+ end
48
+
49
+ # Get content to sign (hash of ByteRange content)
50
+ # HexaPDF calculates this during the signing process
51
+ # @return [String] placeholder - actual content determined during apply_signature
52
+ def content_to_sign
53
+ prepare_for_signing if @prepared_data.nil?
54
+
55
+ # For PDF signing, the actual content to sign is determined by ByteRange
56
+ # during the signature embedding process. We return a placeholder here
57
+ # and handle the actual signing in apply_signature via a custom handler.
58
+ #
59
+ # The real signing happens through ExternalSigningHandler which receives
60
+ # the ByteRange content from HexaPDF.
61
+ "PDF_SIGNING_PLACEHOLDER"
62
+ end
63
+
64
+ # Apply signature to PDF
65
+ # @param signature_or_callback [String, Proc] raw signature bytes or signing callback
66
+ # @param certificate_chain [Array<OpenSSL::X509::Certificate>] certificate chain
67
+ # @param timestamp_token [Timestamp::Response, Proc, nil] optional timestamp or lazy accessor
68
+ # @return [String] path to signed PDF
69
+ def apply_signature(signature_or_callback, certificate_chain, timestamp_token: nil)
70
+ prepare_for_signing if @prepared_data.nil?
71
+
72
+ signing_certificate = certificate_chain.first
73
+
74
+ # Create the signing handler with external signing support
75
+ signing_key = if signature_or_callback.respond_to?(:call)
76
+ # Callback-based signing (for HSM)
77
+ ExternalSigningCallback.new(signature_or_callback)
78
+ else
79
+ # Pre-computed signature
80
+ ExternalSigningProxy.new(signature_or_callback, signing_certificate, certificate_chain)
81
+ end
82
+
83
+ # Estimate signature size for placeholder
84
+ estimated_size = calculate_signature_size(certificate_chain, timestamp_token)
85
+
86
+ # Configure signature handler
87
+ handler = @document.signatures.handler_for_signing(
88
+ @signature_field,
89
+ certificate: signing_certificate,
90
+ key: signing_key,
91
+ certificate_chain: certificate_chain[1..] || [],
92
+ reason: @signature_config[:reason],
93
+ location: @signature_config[:location],
94
+ contact_info: @signature_config[:contact_info],
95
+ signature_size: estimated_size,
96
+ timestamp_handler: timestamp_token ? Pdf::TimestampHandler.new(timestamp_token) : nil
97
+ )
98
+
99
+ # Build visible appearance if configured
100
+ if @signature_config[:visible]
101
+ build_visible_appearance(handler, signing_certificate)
102
+ end
103
+
104
+ # Write signed PDF
105
+ out_path = output_path
106
+ @document.signatures.sign(@signature_field, handler, write_options: { output: out_path })
107
+
108
+ out_path
109
+ end
110
+
111
+ # Phase 1 of deferred signing: prepare PDF with placeholder signature,
112
+ # capture the digest that needs to be signed externally.
113
+ #
114
+ # HexaPDF builds the CMS signed attributes internally (since certificate IS set).
115
+ # The external_signing lambda receives (digest_algorithm, hash) where hash is the
116
+ # digest of the DER-encoded signed attributes — exactly what the external signer
117
+ # must sign. We capture it and return "" to leave the /Contents zero-filled.
118
+ #
119
+ # @param certificate [OpenSSL::X509::Certificate] signing certificate
120
+ # @param certificate_chain [Array<OpenSSL::X509::Certificate>] full chain
121
+ # @param digest_algorithm [String] "sha256", "sha384", or "sha512"
122
+ # @param timestamp_size [Integer] extra bytes to reserve for timestamp (0 if none)
123
+ # @return [DeferredSigningRequest]
124
+ def prepare_deferred(certificate, certificate_chain, digest_algorithm: "sha256", timestamp_size: 0)
125
+ captured_digest = nil
126
+ captured_algorithm = nil
127
+ signing_time = Time.now
128
+
129
+ external_signing = lambda do |algo, hash|
130
+ captured_algorithm = algo
131
+ captured_digest = hash
132
+ "" # Empty string signals async to HexaPDF
133
+ end
134
+
135
+ estimated_size = calculate_deferred_signature_size(certificate_chain, timestamp_size)
136
+
137
+ document = HexaPDF::Document.open(file_path)
138
+ handler = document.signatures.signing_handler(
139
+ certificate: certificate,
140
+ certificate_chain: certificate_chain[1..] || [],
141
+ external_signing: external_signing,
142
+ digest_algorithm: digest_algorithm,
143
+ signature_size: estimated_size,
144
+ signing_time: signing_time,
145
+ reason: @signature_config[:reason],
146
+ location: @signature_config[:location],
147
+ contact_info: @signature_config[:contact_info]
148
+ )
149
+
150
+ prepared_path = deferred_output_path
151
+ document.signatures.add(prepared_path, handler)
152
+
153
+ # Read back the ByteRange from the prepared PDF
154
+ byte_range = read_byte_range(prepared_path)
155
+
156
+ # Compute pre-hash signed attributes DER for WebCrypto compatibility
157
+ signed_attrs_der = compute_signed_attributes_data(
158
+ prepared_path, byte_range, certificate, certificate_chain,
159
+ digest_algorithm, signing_time
160
+ )
161
+
162
+ DeferredSigningRequest.new(
163
+ digest: captured_digest,
164
+ digest_algorithm: captured_algorithm,
165
+ prepared_pdf_path: prepared_path,
166
+ byte_range: byte_range,
167
+ certificate: certificate,
168
+ certificate_chain: certificate_chain,
169
+ estimated_size: estimated_size,
170
+ signing_time: signing_time,
171
+ signed_attributes_data: signed_attrs_der
172
+ )
173
+ rescue HexaPDF::Error => e
174
+ raise DeferredSigningError, "Failed to prepare PDF for deferred signing: #{e.message}"
175
+ end
176
+
177
+ # Phase 2 of deferred signing: rebuild CMS with the real signature and embed it.
178
+ #
179
+ # Re-reads the ByteRange content from the prepared PDF, invokes SignedDataCreator
180
+ # with the same parameters as Phase 1 (including signing_time for determinism),
181
+ # and the block returns the actual raw signature. The resulting CMS DER is embedded
182
+ # into the prepared PDF via Signing.embed_signature.
183
+ #
184
+ # @param deferred_request [DeferredSigningRequest] from Phase 1
185
+ # @param raw_signature [String] raw signature bytes from external signer
186
+ # @return [String] path to the finalized signed PDF
187
+ def finalize_deferred(deferred_request, raw_signature, timestamp_token: nil)
188
+ prepared_path = deferred_request.prepared_pdf_path
189
+ unless File.exist?(prepared_path)
190
+ raise DeferredSigningError, "Prepared PDF not found: #{prepared_path}"
191
+ end
192
+
193
+ byte_range = deferred_request.byte_range
194
+
195
+ # Read ByteRange content from the prepared PDF
196
+ data = File.open(prepared_path, "rb") do |f|
197
+ f.pos = byte_range[0]
198
+ content = f.read(byte_range[1])
199
+ f.pos = byte_range[2]
200
+ content << f.read(byte_range[3])
201
+ end
202
+
203
+ # Rebuild the CMS structure with the actual signature
204
+ signing_block = lambda do |_digest_algorithm, _hash|
205
+ raw_signature
206
+ end
207
+
208
+ creator = HexaPDF::DigitalSignature::Signing::SignedDataCreator.new
209
+ creator.certificate = deferred_request.certificate
210
+ creator.digest_algorithm = deferred_request.digest_algorithm.to_s
211
+ creator.signing_time = deferred_request.signing_time
212
+ creator.certificates = deferred_request.certificate_chain[1..] || []
213
+ creator.timestamp_handler = Pdf::TimestampHandler.new(timestamp_token) if timestamp_token
214
+
215
+ cms = creator.create(data, type: :cms, &signing_block)
216
+
217
+ cms_der = cms.to_der
218
+
219
+ # Embed the real signature into the prepared PDF
220
+ File.open(prepared_path, "rb+") do |io|
221
+ HexaPDF::DigitalSignature::Signing.embed_signature(io, cms_der)
222
+ end
223
+
224
+ prepared_path
225
+ rescue HexaPDF::Error => e
226
+ raise DeferredSigningError, "Failed to finalize deferred signature: #{e.message}"
227
+ end
228
+
229
+ # Extract existing signature from PDF
230
+ # @return [Hash, nil] signature data or nil if unsigned
231
+ def extract_signature
232
+ doc = HexaPDF::Document.open(file_path)
233
+
234
+ signatures = doc.signatures.each.to_a
235
+ return nil if signatures.empty?
236
+
237
+ # HexaPDF's signatures.each yields the Sig dictionary directly
238
+ sig_dict = signatures.last
239
+
240
+ return nil unless sig_dict
241
+
242
+ {
243
+ contents: sig_dict[:Contents],
244
+ byte_range: sig_dict[:ByteRange]&.value,
245
+ sub_filter: sig_dict[:SubFilter]&.to_s,
246
+ reason: sig_dict[:Reason],
247
+ location: sig_dict[:Location],
248
+ contact_info: sig_dict[:ContactInfo],
249
+ signing_time: sig_dict[:M],
250
+ name: sig_dict[:Name]
251
+ }
252
+ rescue StandardError
253
+ nil
254
+ end
255
+
256
+ private
257
+
258
+ def validate_pdf!
259
+ ext = File.extname(file_path).downcase
260
+ unless SUPPORTED_EXTENSIONS.include?(ext)
261
+ raise InvalidPdfError, "File must be a PDF: #{file_path}"
262
+ end
263
+
264
+ # Verify PDF header
265
+ File.open(file_path, "rb") do |f|
266
+ header = f.read(8)
267
+ unless header&.start_with?("%PDF-")
268
+ raise InvalidPdfError, "Invalid PDF file (bad header): #{file_path}"
269
+ end
270
+ end
271
+ end
272
+
273
+ def build_signature_config(opts)
274
+ {
275
+ visible: opts.fetch(:visible_signature, false),
276
+ page: opts.fetch(:signature_page, 1),
277
+ position: opts.fetch(:signature_position, :bottom_right),
278
+ rect: opts[:signature_rect],
279
+ reason: opts[:signature_reason],
280
+ location: opts[:signature_location],
281
+ contact_info: opts[:signature_contact]
282
+ }
283
+ end
284
+
285
+ def create_signature_field
286
+ page_index = [@signature_config[:page] - 1, 0].max
287
+ page = @document.pages[page_index] || @document.pages.add
288
+
289
+ # Create signature form field
290
+ form = @document.acro_form(create: true)
291
+ sig_field = form.create_signature_field("Signature1")
292
+
293
+ # Add visible appearance if requested
294
+ if @signature_config[:visible]
295
+ sig_field.create_widget(page, Rect: calculate_signature_rect(page))
296
+ # Appearance will be built during signing
297
+ else
298
+ # Invisible signature
299
+ sig_field.create_widget(page, Rect: [0, 0, 0, 0])
300
+ end
301
+
302
+ sig_field
303
+ end
304
+
305
+ def calculate_signature_rect(page)
306
+ if @signature_config[:rect]
307
+ return @signature_config[:rect]
308
+ end
309
+
310
+ # Calculate position based on preset
311
+ box = page.box(:media)
312
+ width = 200
313
+ height = 50
314
+ margin = 36
315
+
316
+ case @signature_config[:position].to_sym
317
+ when :top_left
318
+ [margin, box.height - margin - height, margin + width, box.height - margin]
319
+ when :top_right
320
+ [box.width - margin - width, box.height - margin - height, box.width - margin, box.height - margin]
321
+ when :bottom_left
322
+ [margin, margin, margin + width, margin + height]
323
+ when :bottom_right
324
+ [box.width - margin - width, margin, box.width - margin, margin + height]
325
+ else
326
+ [box.width - margin - width, margin, box.width - margin, margin + height]
327
+ end
328
+ end
329
+
330
+ def calculate_deferred_signature_size(certificate_chain, timestamp_size)
331
+ base_size = 8192
332
+ cert_size = certificate_chain.sum { |c| c.to_der.bytesize }
333
+ base_size + cert_size + timestamp_size
334
+ end
335
+
336
+ def deferred_output_path
337
+ dir = File.dirname(file_path)
338
+ base = File.basename(file_path, File.extname(file_path))
339
+ File.join(dir, "#{base}_prepared.pdf")
340
+ end
341
+
342
+ def read_byte_range(pdf_path)
343
+ doc = HexaPDF::Document.open(pdf_path)
344
+ sig = doc.signatures.each.to_a.last
345
+ sig_dict = sig.is_a?(Hash) ? sig : sig
346
+ sig_dict[:ByteRange]&.value
347
+ end
348
+
349
+ # Reconstruct the DER-encoded signed attributes from the prepared PDF.
350
+ # This is the pre-hash data that WebCrypto can hash-and-sign in one step.
351
+ # Invariant: SHA256(result) == captured_digest
352
+ def compute_signed_attributes_data(prepared_path, byte_range, certificate, certificate_chain,
353
+ digest_algorithm, signing_time)
354
+ # Read ByteRange content (same as finalize_deferred does)
355
+ data = File.open(prepared_path, "rb") do |f|
356
+ f.pos = byte_range[0]
357
+ content = f.read(byte_range[1])
358
+ f.pos = byte_range[2]
359
+ content << f.read(byte_range[3])
360
+ end
361
+
362
+ # Build a SignedDataCreator with the same params used in Phase 1
363
+ creator = HexaPDF::DigitalSignature::Signing::SignedDataCreator.new
364
+ creator.certificate = certificate
365
+ creator.digest_algorithm = digest_algorithm.to_s
366
+ creator.signing_time = signing_time
367
+ creator.certificates = certificate_chain[1..] || []
368
+
369
+ # Access the private method to get the ASN.1 signed attributes SET
370
+ signed_attrs = creator.send(:create_signed_attrs, data, signing_time: true)
371
+
372
+ # DER-encode the SET (mirrors line 113 of signed_data_creator.rb)
373
+ OpenSSL::ASN1::Set.new(signed_attrs.value).to_der
374
+ end
375
+
376
+ def calculate_signature_size(certificate_chain, timestamp_token)
377
+ # Estimate PKCS#7 signature size
378
+ # Base size + certificates + timestamp
379
+ base_size = 8192
380
+ cert_size = certificate_chain.sum { |c| c.to_der.bytesize }
381
+ timestamp_size = timestamp_token ? 4096 : 0
382
+
383
+ base_size + cert_size + timestamp_size
384
+ end
385
+
386
+ def build_visible_appearance(handler, certificate)
387
+ # Get the widget annotation for the signature field
388
+ widget = @signature_field.each_widget.first
389
+ return unless widget
390
+
391
+ rect = widget[:Rect].value
392
+ width = rect[2] - rect[0]
393
+ height = rect[3] - rect[1]
394
+
395
+ # Create appearance form XObject
396
+ form = @document.add({ Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height] })
397
+ canvas = form.canvas
398
+
399
+ # Draw border
400
+ canvas.stroke_color(0, 0, 0)
401
+ canvas.line_width(0.5)
402
+ canvas.rectangle(0.5, 0.5, width - 1, height - 1)
403
+ canvas.stroke
404
+
405
+ # Draw signature text
406
+ canvas.font("Helvetica", size: 8)
407
+ canvas.fill_color(0, 0, 0)
408
+
409
+ y_pos = height - 12
410
+ x_pos = 5
411
+
412
+ # Signer name
413
+ signer_name = extract_cn_from_certificate(certificate)
414
+ canvas.text("Digitally signed by:", at: [x_pos, y_pos])
415
+ y_pos -= 10
416
+ canvas.text(signer_name, at: [x_pos, y_pos])
417
+ y_pos -= 12
418
+
419
+ # Reason
420
+ if @signature_config[:reason]
421
+ canvas.text("Reason: #{@signature_config[:reason]}", at: [x_pos, y_pos])
422
+ y_pos -= 10
423
+ end
424
+
425
+ # Location
426
+ if @signature_config[:location]
427
+ canvas.text("Location: #{@signature_config[:location]}", at: [x_pos, y_pos])
428
+ y_pos -= 10
429
+ end
430
+
431
+ # Date
432
+ canvas.text("Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", at: [x_pos, y_pos])
433
+
434
+ # Set the appearance
435
+ widget[:AP] = { N: form }
436
+ end
437
+
438
+ def extract_cn_from_certificate(certificate)
439
+ subject = certificate.subject.to_a
440
+ cn = subject.find { |name, _, _| name == "CN" }
441
+ cn ? cn[1] : certificate.subject.to_s
442
+ end
443
+ end
444
+
445
+ # Callback-based signing for HSM integration
446
+ # HexaPDF calls #sign with the actual data to sign (ByteRange content)
447
+ class ExternalSigningCallback
448
+ def initialize(signing_proc)
449
+ @signing_proc = signing_proc
450
+ end
451
+
452
+ # Called by HexaPDF during signing with the ByteRange content
453
+ def sign(data, _digest_algorithm)
454
+ @signing_proc.call(data)
455
+ end
456
+
457
+ def private?
458
+ true
459
+ end
460
+ end
461
+
462
+ # Proxy object that provides pre-computed signature to HexaPDF
463
+ # HexaPDF expects a key object that responds to #sign, but we've already
464
+ # signed with the HSM, so we return the pre-computed signature
465
+ class ExternalSigningProxy
466
+ attr_reader :certificate
467
+
468
+ def initialize(signature, certificate, certificate_chain)
469
+ @signature = signature
470
+ @certificate = certificate
471
+ @certificate_chain = certificate_chain
472
+ end
473
+
474
+ # Called by HexaPDF's DefaultHandler during signing
475
+ # Returns pre-computed signature from HSM
476
+ def sign(data, digest_algorithm)
477
+ @signature
478
+ end
479
+
480
+ # HexaPDF checks this for RSA keys
481
+ def private?
482
+ true
483
+ end
484
+ end
485
+ end
486
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "openssl"
5
+ require "tempfile"
6
+ require "fileutils"
7
+ require "base64"
8
+
9
+ module EasyCodeSign
10
+ module Signable
11
+ # Handler for signing ZIP files using JAR-style signatures
12
+ #
13
+ # Creates META-INF/ directory with:
14
+ # - MANIFEST.MF: Contains hashes of all files
15
+ # - CERT.SF: Contains hash of manifest (this is what gets signed)
16
+ # - CERT.RSA: PKCS#7 signature block with certificate chain
17
+ #
18
+ # @example
19
+ # zip_file = EasyCodeSign::Signable::ZipFile.new("archive.zip")
20
+ # zip_file.prepare_for_signing
21
+ # signature = provider.sign(zip_file.content_to_sign, algorithm: :sha256_rsa)
22
+ # zip_file.apply_signature(signature, [cert])
23
+ #
24
+ class ZipFile < Base
25
+ MANIFEST_PATH = "META-INF/MANIFEST.MF"
26
+ SIGNATURE_FILE_PATH = "META-INF/CERT.SF"
27
+ SIGNATURE_BLOCK_PATH = "META-INF/CERT.RSA"
28
+ MANIFEST_VERSION = "1.0"
29
+ SIGNATURE_VERSION = "1.0"
30
+
31
+ def initialize(file_path, **options)
32
+ super
33
+ validate_zip_format!
34
+ @manifest_entries = {}
35
+ @manifest_content = nil
36
+ @signature_file_content = nil
37
+ end
38
+
39
+ def prepare_for_signing
40
+ build_manifest
41
+ build_signature_file
42
+ end
43
+
44
+ # Returns the signature file content that needs to be signed
45
+ def content_to_sign
46
+ prepare_for_signing if @signature_file_content.nil?
47
+ compute_hash(@signature_file_content)
48
+ end
49
+
50
+ def apply_signature(signature, certificate_chain, timestamp_token: nil)
51
+ prepare_for_signing if @signature_file_content.nil?
52
+
53
+ pkcs7_block = build_signature_block(signature, certificate_chain, timestamp_token)
54
+ write_signed_zip(pkcs7_block)
55
+ end
56
+
57
+ def extract_signature
58
+ ::Zip::File.open(file_path) do |zip|
59
+ return nil unless zip.find_entry(SIGNATURE_BLOCK_PATH)
60
+
61
+ {
62
+ manifest: zip.read(MANIFEST_PATH),
63
+ signature_file: zip.read(SIGNATURE_FILE_PATH),
64
+ signature_block: zip.read(SIGNATURE_BLOCK_PATH)
65
+ }
66
+ end
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ # Get list of files in the ZIP (excluding META-INF signatures)
72
+ # @return [Array<String>]
73
+ def file_list
74
+ files = []
75
+ ::Zip::File.open(file_path) do |zip|
76
+ zip.each do |entry|
77
+ next if entry.directory?
78
+ next if entry.name.start_with?("META-INF/") && signature_file?(entry.name)
79
+
80
+ files << entry.name
81
+ end
82
+ end
83
+ files.sort
84
+ end
85
+
86
+ private
87
+
88
+ def validate_zip_format!
89
+ unless file_path.end_with?(".zip", ".jar", ".apk", ".war", ".ear")
90
+ raise InvalidFileError, "File must be a ZIP archive: #{file_path}"
91
+ end
92
+
93
+ # Verify it's a valid ZIP
94
+ ::Zip::File.open(file_path) { |_| } # Just check we can open it
95
+ rescue ::Zip::Error => e
96
+ raise InvalidFileError, "Invalid ZIP file: #{e.message}"
97
+ end
98
+
99
+ def signature_file?(name)
100
+ basename = File.basename(name)
101
+ basename == "MANIFEST.MF" ||
102
+ basename.end_with?(".SF", ".RSA", ".DSA", ".EC")
103
+ end
104
+
105
+ def build_manifest
106
+ @manifest_entries = {}
107
+ lines = ["Manifest-Version: #{MANIFEST_VERSION}", "Created-By: EasyCodeSign", ""]
108
+
109
+ ::Zip::File.open(file_path) do |zip|
110
+ zip.each do |entry|
111
+ next if entry.directory?
112
+ next if entry.name.start_with?("META-INF/") && signature_file?(entry.name)
113
+
114
+ content = entry.get_input_stream.read
115
+ digest = Base64.strict_encode64(compute_hash(content))
116
+
117
+ @manifest_entries[entry.name] = digest
118
+
119
+ lines << "Name: #{entry.name}"
120
+ lines << "#{digest_attribute_name}: #{digest}"
121
+ lines << ""
122
+ end
123
+ end
124
+
125
+ @manifest_content = lines.join("\r\n")
126
+ end
127
+
128
+ def build_signature_file
129
+ lines = [
130
+ "Signature-Version: #{SIGNATURE_VERSION}",
131
+ "Created-By: EasyCodeSign",
132
+ "#{digest_attribute_name}-Digest-Manifest: #{Base64.strict_encode64(compute_hash(@manifest_content))}",
133
+ ""
134
+ ]
135
+
136
+ # Add per-entry digests
137
+ @manifest_entries.each do |name, _|
138
+ entry_section = find_manifest_section(name)
139
+ section_digest = Base64.strict_encode64(compute_hash(entry_section))
140
+
141
+ lines << "Name: #{name}"
142
+ lines << "#{digest_attribute_name}-Digest: #{section_digest}"
143
+ lines << ""
144
+ end
145
+
146
+ @signature_file_content = lines.join("\r\n")
147
+ end
148
+
149
+ def find_manifest_section(name)
150
+ # Find the section for this entry in the manifest
151
+ sections = @manifest_content.split(/\r?\n\r?\n/)
152
+ sections.find { |s| s.include?("Name: #{name}") } || ""
153
+ end
154
+
155
+ def digest_attribute_name
156
+ case hash_algorithm
157
+ when :sha256 then "SHA-256"
158
+ when :sha384 then "SHA-384"
159
+ when :sha512 then "SHA-512"
160
+ else "SHA-256"
161
+ end
162
+ end
163
+
164
+ def build_signature_block(signature, certificate_chain, timestamp_token)
165
+ # signing_cert = certificate_chain.first (used when embedding signer info)
166
+
167
+ # Create PKCS#7 SignedData structure
168
+ pkcs7 = OpenSSL::PKCS7.new
169
+ pkcs7.type = "signed"
170
+
171
+ # Add all certificates in the chain
172
+ certificate_chain.each do |cert|
173
+ pkcs7.add_certificate(cert)
174
+ end
175
+
176
+ # For JAR signing, we sign the .SF file content
177
+ # The signature from HSM needs to be wrapped in PKCS#7
178
+ pkcs7.add_data(@signature_file_content)
179
+
180
+ # Note: In a full implementation, we'd need to properly embed
181
+ # the HSM-generated signature into the PKCS#7 structure.
182
+ # This requires constructing SignerInfo with the pre-made signature.
183
+
184
+ pkcs7.to_der
185
+ end
186
+
187
+ def write_signed_zip(pkcs7_block)
188
+ output = output_path
189
+ temp_file = Tempfile.new(["signed_zip", File.extname(file_path)])
190
+
191
+ begin
192
+ ::Zip::File.open(file_path) do |input_zip|
193
+ ::Zip::File.open(temp_file.path, ::Zip::File::CREATE) do |output_zip|
194
+ # Copy all entries except existing signatures
195
+ input_zip.each do |entry|
196
+ next if entry.name.start_with?("META-INF/") && signature_file?(entry.name)
197
+
198
+ if entry.directory?
199
+ output_zip.mkdir(entry.name)
200
+ else
201
+ output_zip.get_output_stream(entry.name) do |os|
202
+ os.write(entry.get_input_stream.read)
203
+ end
204
+ end
205
+ end
206
+
207
+ # Ensure META-INF directory exists
208
+ output_zip.mkdir("META-INF") unless output_zip.find_entry("META-INF/")
209
+
210
+ # Add signature files
211
+ output_zip.get_output_stream(MANIFEST_PATH) { |os| os.write(@manifest_content) }
212
+ output_zip.get_output_stream(SIGNATURE_FILE_PATH) { |os| os.write(@signature_file_content) }
213
+ output_zip.get_output_stream(SIGNATURE_BLOCK_PATH) { |os| os.write(pkcs7_block) }
214
+ end
215
+ end
216
+
217
+ FileUtils.mv(temp_file.path, output)
218
+ output
219
+ ensure
220
+ temp_file.close
221
+ temp_file.unlink if File.exist?(temp_file.path)
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end