easy_code_sign 0.1.0 → 0.2.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.
@@ -1,253 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hexapdf"
3
+ require_relative "../pdf/native_signer"
4
4
 
5
5
  module EasyCodeSign
6
6
  module Signable
7
- # Handler for signing PDF files
7
+ # Handler for signing PDF files using the native MIT-licensed backend.
8
8
  #
9
- # PDF signatures use a ByteRange approach where specific byte ranges are signed,
10
- # excluding the signature field itself. This allows incremental updates.
9
+ # Signs PDFs using ISO 32000 incremental-update signatures (adbe.pkcs7.detached).
10
+ # No HexaPDF (AGPL) dependency signing is done via NativeSigner + CmsBuilder.
11
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)
12
+ # @example Sign a PDF with a hardware-token provider
13
+ # pdf = PdfFile.new("document.pdf",
14
+ # output_path: "document_signed.pdf",
15
+ # signature_reason: "Approval",
16
+ # signature_location: "Athens")
17
+ # pdf.apply_signature(->(hash) { provider.sign_bytes(hash) }, [cert])
18
18
  #
19
19
  class PdfFile < Base
20
20
  SUPPORTED_EXTENSIONS = %w[.pdf].freeze
21
21
 
22
- # Signature appearance configuration
23
22
  attr_reader :signature_config
24
23
 
25
24
  def initialize(file_path, **options)
26
25
  super
27
26
  validate_pdf!
28
27
  @signature_config = build_signature_config(options)
29
- @document = nil
30
- @signature_field = nil
31
- @prepared_data = nil
32
28
  end
33
29
 
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
30
+ # Apply an ISO 32000 incremental-update signature to this PDF.
31
+ #
32
+ # @param signature_or_callback [String, Proc]
33
+ # - Proc: called with SHA256(signed_attrs_DER) → returns raw RSA signature bytes
34
+ # - String: pre-computed raw RSA signature bytes (used as-is)
35
+ # @param certificate_chain [Array<OpenSSL::X509::Certificate>]
36
+ # First element is the signing certificate; rest are chain certs.
37
+ # @return [String] path to the signed output file
69
38
  def apply_signature(signature_or_callback, certificate_chain, timestamp_token: nil)
70
- prepare_for_signing if @prepared_data.nil?
71
-
72
39
  signing_certificate = certificate_chain.first
73
40
 
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,
41
+ sign_proc = if signature_or_callback.respond_to?(:call)
42
+ signature_or_callback
43
+ else
44
+ raw = signature_or_callback
45
+ ->(_hash) { raw }
46
+ end
47
+
48
+ signer = Pdf::NativeSigner.new(
49
+ pdf_path: file_path,
50
+ output_path: output_path,
51
+ certificate: signing_certificate,
91
52
  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
53
+ reason: @signature_config[:reason],
54
+ location: @signature_config[:location],
55
+ contact_info: @signature_config[:contact_info]
97
56
  )
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
57
+ signer.sign { |hash| sign_proc.call(hash) }
109
58
  end
110
59
 
111
- # Phase 1 of deferred signing: prepare PDF with placeholder signature,
112
- # capture the digest that needs to be signed externally.
60
+ # Extract the last signature from this PDF by scanning the raw bytes.
61
+ # Works for any PDF signed with the native backend (adbe.pkcs7.detached).
113
62
  #
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
63
+ # @return [Hash, nil] :contents (binary DER), :byte_range, :sub_filter or nil if unsigned
231
64
  def extract_signature
232
- doc = HexaPDF::Document.open(file_path)
65
+ raw = File.binread(file_path)
233
66
 
234
- signatures = doc.signatures.each.to_a
235
- return nil if signatures.empty?
67
+ contents_hex = nil
68
+ raw.scan(%r{/Contents\s*<([0-9a-fA-F]*)>}) { |m| contents_hex = m[0] }
69
+ return nil unless contents_hex
236
70
 
237
- # HexaPDF's signatures.each yields the Sig dictionary directly
238
- sig_dict = signatures.last
71
+ br_str = nil
72
+ raw.scan(%r{/ByteRange\s*\[([^\]]+)\]}) { |m| br_str = m[0] }
73
+ return nil unless br_str
239
74
 
240
- return nil unless sig_dict
75
+ br_values = br_str.split.map(&:to_i)
76
+ return nil unless br_values.size == 4
241
77
 
242
78
  {
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]
79
+ contents: [contents_hex].pack("H*"),
80
+ byte_range: br_values,
81
+ sub_filter: raw[%r{/SubFilter\s*/(\S+)}, 1]
251
82
  }
252
83
  rescue StandardError
253
84
  nil
@@ -261,7 +92,6 @@ module EasyCodeSign
261
92
  raise InvalidPdfError, "File must be a PDF: #{file_path}"
262
93
  end
263
94
 
264
- # Verify PDF header
265
95
  File.open(file_path, "rb") do |f|
266
96
  header = f.read(8)
267
97
  unless header&.start_with?("%PDF-")
@@ -272,215 +102,15 @@ module EasyCodeSign
272
102
 
273
103
  def build_signature_config(opts)
274
104
  {
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],
105
+ visible: opts.fetch(:visible_signature, false),
106
+ page: opts.fetch(:signature_page, 1),
107
+ position: opts.fetch(:signature_position, :bottom_right),
108
+ rect: opts[:signature_rect],
109
+ reason: opts[:signature_reason],
110
+ location: opts[:signature_location],
281
111
  contact_info: opts[:signature_contact]
282
112
  }
283
113
  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
114
  end
485
115
  end
486
116
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyCodeSign
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -10,8 +10,6 @@ require_relative "easy_code_sign/signable/base"
10
10
  require_relative "easy_code_sign/signable/gem_file"
11
11
  require_relative "easy_code_sign/signable/zip_file"
12
12
  require_relative "easy_code_sign/signable/pdf_file"
13
- require_relative "easy_code_sign/pdf/timestamp_handler"
14
- require_relative "easy_code_sign/pdf/appearance_builder"
15
13
  require_relative "easy_code_sign/timestamp/request"
16
14
  require_relative "easy_code_sign/timestamp/response"
17
15
  require_relative "easy_code_sign/timestamp/client"