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,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
|