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,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module EasyCodeSign
|
|
6
|
+
# Command-line interface for EasyCodeSign
|
|
7
|
+
#
|
|
8
|
+
# Provides commands for signing and verifying files using hardware tokens.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# $ easysign sign my_gem-1.0.0.gem --pin 1234
|
|
12
|
+
# $ easysign verify signed.gem
|
|
13
|
+
# $ easysign list-slots
|
|
14
|
+
#
|
|
15
|
+
class CLI < Thor
|
|
16
|
+
class_option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose output"
|
|
17
|
+
class_option :quiet, type: :boolean, aliases: "-q", desc: "Suppress non-essential output"
|
|
18
|
+
|
|
19
|
+
def self.exit_on_failure?
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc "sign FILE", "Sign a file (gem, zip, or PDF) using hardware token"
|
|
24
|
+
long_desc <<~DESC
|
|
25
|
+
Sign a file using a hardware security token (HSM/smart card).
|
|
26
|
+
|
|
27
|
+
Supported file types:
|
|
28
|
+
- Ruby gems (.gem)
|
|
29
|
+
- ZIP archives (.zip, .jar, .apk, .war, .ear)
|
|
30
|
+
- PDF documents (.pdf)
|
|
31
|
+
|
|
32
|
+
The signature is embedded in the file. For gems, this creates
|
|
33
|
+
PKCS#7 detached signatures. For PDFs, use --visible-signature
|
|
34
|
+
to add a visible signature annotation.
|
|
35
|
+
DESC
|
|
36
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path (default: overwrite input)"
|
|
37
|
+
option :timestamp, type: :boolean, default: false, aliases: "-t", desc: "Add RFC 3161 timestamp"
|
|
38
|
+
option :tsa, type: :string, desc: "Timestamp authority URL"
|
|
39
|
+
option :algorithm, type: :string, default: "sha256", desc: "Hash algorithm (sha256, sha384, sha512)"
|
|
40
|
+
option :provider, type: :string, default: "safenet", desc: "Token provider (safenet)"
|
|
41
|
+
option :library, type: :string, desc: "Path to PKCS#11 library"
|
|
42
|
+
option :slot, type: :numeric, default: 0, desc: "Token slot index"
|
|
43
|
+
# PDF-specific options
|
|
44
|
+
option :visible_signature, type: :boolean, default: false, desc: "[PDF] Add visible signature annotation"
|
|
45
|
+
option :signature_page, type: :numeric, default: 1, desc: "[PDF] Page number for visible signature"
|
|
46
|
+
option :signature_position, type: :string, default: "bottom_right", desc: "[PDF] Position (top_left, top_right, bottom_left, bottom_right)"
|
|
47
|
+
option :signature_reason, type: :string, desc: "[PDF] Reason for signing"
|
|
48
|
+
option :signature_location, type: :string, desc: "[PDF] Location of signing"
|
|
49
|
+
def sign(file)
|
|
50
|
+
configure_from_options
|
|
51
|
+
|
|
52
|
+
pin = prompt_for_pin
|
|
53
|
+
algorithm = :"#{options[:algorithm]}_rsa"
|
|
54
|
+
|
|
55
|
+
say "Signing #{file}...", :cyan unless options[:quiet]
|
|
56
|
+
|
|
57
|
+
# Build signing options, including PDF-specific ones
|
|
58
|
+
sign_opts = {
|
|
59
|
+
pin: pin,
|
|
60
|
+
output_path: options[:output],
|
|
61
|
+
timestamp: options[:timestamp],
|
|
62
|
+
algorithm: algorithm
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Add PDF options if present
|
|
66
|
+
if File.extname(file).downcase == ".pdf"
|
|
67
|
+
sign_opts[:visible_signature] = options[:visible_signature]
|
|
68
|
+
sign_opts[:signature_page] = options[:signature_page]
|
|
69
|
+
sign_opts[:signature_position] = options[:signature_position]&.to_sym
|
|
70
|
+
sign_opts[:signature_reason] = options[:signature_reason]
|
|
71
|
+
sign_opts[:signature_location] = options[:signature_location]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result = EasyCodeSign.sign(file, **sign_opts)
|
|
75
|
+
|
|
76
|
+
if options[:verbose]
|
|
77
|
+
say "\nSigning complete:", :green
|
|
78
|
+
say " File: #{result.file_path}"
|
|
79
|
+
say " Signer: #{result.signer_name}"
|
|
80
|
+
say " Algorithm: #{result.algorithm}"
|
|
81
|
+
say " Timestamped: #{result.timestamped? ? 'Yes' : 'No'}"
|
|
82
|
+
say " Signed at: #{result.signed_at}"
|
|
83
|
+
else
|
|
84
|
+
say "Signed: #{result.file_path}", :green unless options[:quiet]
|
|
85
|
+
end
|
|
86
|
+
rescue EasyCodeSign::Error => e
|
|
87
|
+
say_error "Signing failed: #{e.message}"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
desc "verify FILE", "Verify a signed file"
|
|
92
|
+
long_desc <<~DESC
|
|
93
|
+
Verify the signature on a signed file.
|
|
94
|
+
|
|
95
|
+
Checks:
|
|
96
|
+
- Cryptographic signature validity
|
|
97
|
+
- File integrity (not tampered)
|
|
98
|
+
- Certificate validity and trust chain
|
|
99
|
+
- Timestamp validity (if present)
|
|
100
|
+
DESC
|
|
101
|
+
option :trust_store, type: :string, desc: "Path to custom CA certificates"
|
|
102
|
+
option :no_timestamp, type: :boolean, default: false, desc: "Skip timestamp verification"
|
|
103
|
+
option :json, type: :boolean, default: false, desc: "Output result as JSON"
|
|
104
|
+
def verify(file)
|
|
105
|
+
trust_store = nil
|
|
106
|
+
if options[:trust_store]
|
|
107
|
+
trust_store = Verification::TrustStore.new
|
|
108
|
+
if File.directory?(options[:trust_store])
|
|
109
|
+
trust_store.add_directory(options[:trust_store])
|
|
110
|
+
else
|
|
111
|
+
trust_store.add_file(options[:trust_store])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
say "Verifying #{file}...", :cyan unless options[:quiet]
|
|
116
|
+
|
|
117
|
+
result = EasyCodeSign.verify(
|
|
118
|
+
file,
|
|
119
|
+
check_timestamp: !options[:no_timestamp],
|
|
120
|
+
trust_store: trust_store
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if options[:json]
|
|
124
|
+
require "json"
|
|
125
|
+
say JSON.pretty_generate(result.to_h)
|
|
126
|
+
else
|
|
127
|
+
display_verification_result(result)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
exit 1 unless result.valid?
|
|
131
|
+
rescue EasyCodeSign::Error => e
|
|
132
|
+
say_error "Verification failed: #{e.message}"
|
|
133
|
+
exit 1
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
desc "list-slots", "List available token slots"
|
|
137
|
+
long_desc <<~DESC
|
|
138
|
+
List all available PKCS#11 token slots.
|
|
139
|
+
|
|
140
|
+
Shows information about connected hardware tokens including:
|
|
141
|
+
- Slot index
|
|
142
|
+
- Token label
|
|
143
|
+
- Manufacturer
|
|
144
|
+
- Serial number
|
|
145
|
+
DESC
|
|
146
|
+
option :provider, type: :string, default: "safenet", desc: "Token provider"
|
|
147
|
+
option :library, type: :string, desc: "Path to PKCS#11 library"
|
|
148
|
+
def list_slots
|
|
149
|
+
configure_from_options
|
|
150
|
+
|
|
151
|
+
slots = EasyCodeSign.list_slots
|
|
152
|
+
|
|
153
|
+
if slots.empty?
|
|
154
|
+
say "No tokens found", :yellow
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
say "Available token slots:", :cyan
|
|
159
|
+
say ""
|
|
160
|
+
|
|
161
|
+
slots.each do |slot|
|
|
162
|
+
say "Slot #{slot[:index]}:"
|
|
163
|
+
say " Description: #{slot[:description]}"
|
|
164
|
+
if slot[:token_present]
|
|
165
|
+
say " Token: #{slot[:token_label]}", :green
|
|
166
|
+
say " Manufacturer: #{slot[:manufacturer]}"
|
|
167
|
+
say " Serial: #{slot[:serial]}"
|
|
168
|
+
else
|
|
169
|
+
say " Token: (not present)", :yellow
|
|
170
|
+
end
|
|
171
|
+
say ""
|
|
172
|
+
end
|
|
173
|
+
rescue EasyCodeSign::Error => e
|
|
174
|
+
say_error "Failed to list slots: #{e.message}"
|
|
175
|
+
exit 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
desc "prepare-pdf FILE", "Prepare a PDF for deferred (two-phase) signing"
|
|
179
|
+
long_desc <<~DESC
|
|
180
|
+
Phase 1 of deferred PDF signing.
|
|
181
|
+
|
|
182
|
+
Prepares the PDF with a placeholder signature and outputs the digest
|
|
183
|
+
that must be signed by an external signer (Fortify, WebCrypto, remote HSM).
|
|
184
|
+
|
|
185
|
+
The prepared PDF and a JSON request file are written alongside the input.
|
|
186
|
+
Send the digest to your external signing service, then use finalize-pdf
|
|
187
|
+
to embed the real signature.
|
|
188
|
+
DESC
|
|
189
|
+
option :algorithm, type: :string, default: "sha256", desc: "Hash algorithm (sha256, sha384, sha512)"
|
|
190
|
+
option :timestamp, type: :boolean, default: false, aliases: "-t", desc: "Reserve space for timestamp"
|
|
191
|
+
option :provider, type: :string, default: "safenet", desc: "Token provider (safenet)"
|
|
192
|
+
option :library, type: :string, desc: "Path to PKCS#11 library"
|
|
193
|
+
option :slot, type: :numeric, default: 0, desc: "Token slot index"
|
|
194
|
+
option :json, type: :boolean, default: false, desc: "Output result as JSON"
|
|
195
|
+
option :signature_reason, type: :string, desc: "[PDF] Reason for signing"
|
|
196
|
+
option :signature_location, type: :string, desc: "[PDF] Location of signing"
|
|
197
|
+
def prepare_pdf(file)
|
|
198
|
+
configure_from_options
|
|
199
|
+
|
|
200
|
+
pin = prompt_for_pin
|
|
201
|
+
|
|
202
|
+
say "Preparing #{file} for deferred signing...", :cyan unless options[:quiet]
|
|
203
|
+
|
|
204
|
+
prepare_opts = {
|
|
205
|
+
pin: pin,
|
|
206
|
+
digest_algorithm: options[:algorithm],
|
|
207
|
+
timestamp: options[:timestamp],
|
|
208
|
+
signature_reason: options[:signature_reason],
|
|
209
|
+
signature_location: options[:signature_location]
|
|
210
|
+
}.compact
|
|
211
|
+
|
|
212
|
+
request = EasyCodeSign.prepare_pdf(file, **prepare_opts)
|
|
213
|
+
|
|
214
|
+
# Write request JSON for later use
|
|
215
|
+
request_path = "#{request.prepared_pdf_path}.json"
|
|
216
|
+
require "json"
|
|
217
|
+
File.write(request_path, JSON.pretty_generate(request.to_h))
|
|
218
|
+
|
|
219
|
+
if options[:json]
|
|
220
|
+
say JSON.pretty_generate(request.to_h)
|
|
221
|
+
else
|
|
222
|
+
say "Prepared: #{request.prepared_pdf_path}", :green unless options[:quiet]
|
|
223
|
+
say "Request saved: #{request_path}" unless options[:quiet]
|
|
224
|
+
say "" unless options[:quiet]
|
|
225
|
+
say "Digest (hex): #{request.digest_hex}" unless options[:quiet]
|
|
226
|
+
say "Digest (base64): #{request.digest_base64}" unless options[:quiet]
|
|
227
|
+
say "" unless options[:quiet]
|
|
228
|
+
say "Sign the digest externally, then run:", :cyan unless options[:quiet]
|
|
229
|
+
say " easysign finalize-pdf #{request.prepared_pdf_path} SIGNATURE_FILE" unless options[:quiet]
|
|
230
|
+
end
|
|
231
|
+
rescue EasyCodeSign::Error => e
|
|
232
|
+
say_error "Prepare failed: #{e.message}"
|
|
233
|
+
exit 1
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
desc "finalize-pdf PREPARED_PDF SIGNATURE_FILE", "Finalize a deferred PDF signature"
|
|
237
|
+
long_desc <<~DESC
|
|
238
|
+
Phase 2 of deferred PDF signing.
|
|
239
|
+
|
|
240
|
+
Reads the prepared PDF and its accompanying .json request file, then
|
|
241
|
+
embeds the raw signature from SIGNATURE_FILE into the PDF.
|
|
242
|
+
|
|
243
|
+
SIGNATURE_FILE should contain the raw signature bytes (binary DER) produced
|
|
244
|
+
by signing the digest from the prepare-pdf step.
|
|
245
|
+
DESC
|
|
246
|
+
option :timestamp, type: :boolean, default: false, aliases: "-t", desc: "Add RFC 3161 timestamp"
|
|
247
|
+
option :tsa, type: :string, desc: "Timestamp authority URL"
|
|
248
|
+
option :request_json, type: :string, desc: "Path to request JSON (default: PREPARED_PDF.json)"
|
|
249
|
+
def finalize_pdf(prepared_pdf, signature_file)
|
|
250
|
+
require "json"
|
|
251
|
+
|
|
252
|
+
request_path = options[:request_json] || "#{prepared_pdf}.json"
|
|
253
|
+
unless File.exist?(request_path)
|
|
254
|
+
say_error "Request file not found: #{request_path}"
|
|
255
|
+
say_error "Specify --request-json or ensure the .json file from prepare-pdf exists"
|
|
256
|
+
exit 1
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
unless File.exist?(signature_file)
|
|
260
|
+
say_error "Signature file not found: #{signature_file}"
|
|
261
|
+
exit 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
configure_from_options
|
|
265
|
+
|
|
266
|
+
say "Finalizing deferred signature on #{prepared_pdf}...", :cyan unless options[:quiet]
|
|
267
|
+
|
|
268
|
+
request_hash = JSON.parse(File.read(request_path))
|
|
269
|
+
request = EasyCodeSign::DeferredSigningRequest.from_h(request_hash)
|
|
270
|
+
raw_signature = File.binread(signature_file)
|
|
271
|
+
|
|
272
|
+
result = EasyCodeSign.finalize_pdf(request, raw_signature, timestamp: options[:timestamp])
|
|
273
|
+
|
|
274
|
+
if options[:verbose]
|
|
275
|
+
say "\nSigning complete:", :green
|
|
276
|
+
say " File: #{result.file_path}"
|
|
277
|
+
say " Signer: #{result.signer_name}"
|
|
278
|
+
say " Algorithm: #{result.algorithm}"
|
|
279
|
+
say " Timestamped: #{result.timestamped? ? 'Yes' : 'No'}"
|
|
280
|
+
say " Signed at: #{result.signed_at}"
|
|
281
|
+
else
|
|
282
|
+
say "Signed: #{result.file_path}", :green unless options[:quiet]
|
|
283
|
+
end
|
|
284
|
+
rescue EasyCodeSign::Error => e
|
|
285
|
+
say_error "Finalize failed: #{e.message}"
|
|
286
|
+
exit 1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
desc "info FILE", "Show signature information for a signed file"
|
|
290
|
+
long_desc <<~DESC
|
|
291
|
+
Display detailed information about a file's signature without
|
|
292
|
+
performing full verification.
|
|
293
|
+
DESC
|
|
294
|
+
def info(file)
|
|
295
|
+
signable = EasyCodeSign.signable_for(file)
|
|
296
|
+
signature = signable.extract_signature
|
|
297
|
+
|
|
298
|
+
unless signature
|
|
299
|
+
say "File is not signed", :yellow
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
say "Signature information for #{file}:", :cyan
|
|
304
|
+
say ""
|
|
305
|
+
|
|
306
|
+
case signable
|
|
307
|
+
when Signable::GemFile
|
|
308
|
+
display_gem_signature_info(signature)
|
|
309
|
+
when Signable::ZipFile
|
|
310
|
+
display_zip_signature_info(signature)
|
|
311
|
+
when Signable::PdfFile
|
|
312
|
+
display_pdf_signature_info(signature)
|
|
313
|
+
end
|
|
314
|
+
rescue EasyCodeSign::Error => e
|
|
315
|
+
say_error "Failed to read signature: #{e.message}"
|
|
316
|
+
exit 1
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
desc "version", "Show version information"
|
|
320
|
+
def version
|
|
321
|
+
say "EasyCodeSign version #{EasyCodeSign::VERSION}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
map %w[-V --version] => :version
|
|
325
|
+
|
|
326
|
+
private
|
|
327
|
+
|
|
328
|
+
def configure_from_options
|
|
329
|
+
EasyCodeSign.configure do |config|
|
|
330
|
+
config.provider = options[:provider].to_sym if options[:provider]
|
|
331
|
+
config.pkcs11_library = options[:library] if options[:library]
|
|
332
|
+
config.slot_index = options[:slot] if options[:slot]
|
|
333
|
+
config.timestamp_authority = options[:tsa] if options[:tsa]
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def prompt_for_pin
|
|
338
|
+
say "Enter PIN: ", :cyan
|
|
339
|
+
pin = $stdin.noecho(&:gets)&.chomp
|
|
340
|
+
say "" # Newline after hidden input
|
|
341
|
+
pin
|
|
342
|
+
rescue Errno::ENOENT, NoMethodError
|
|
343
|
+
# noecho not available (e.g., not a terminal)
|
|
344
|
+
say "Enter PIN: ", :cyan
|
|
345
|
+
$stdin.gets&.chomp
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def display_verification_result(result)
|
|
349
|
+
if result.valid?
|
|
350
|
+
say "\n✓ Signature is VALID", :green
|
|
351
|
+
else
|
|
352
|
+
say "\n✗ Signature is INVALID", :red
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
say ""
|
|
356
|
+
|
|
357
|
+
if result.signer_name
|
|
358
|
+
say "Signer: #{result.signer_name}"
|
|
359
|
+
say "Organization: #{result.signer_organization}" if result.signer_organization
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
say ""
|
|
363
|
+
say "Checks:"
|
|
364
|
+
display_check " Signature", result.signature_valid?
|
|
365
|
+
display_check " Integrity", result.integrity_valid?
|
|
366
|
+
display_check " Certificate", result.certificate_valid?
|
|
367
|
+
display_check " Trust chain", result.chain_valid?
|
|
368
|
+
display_check " Trusted root", result.trusted?
|
|
369
|
+
|
|
370
|
+
if result.timestamped?
|
|
371
|
+
say ""
|
|
372
|
+
say "Timestamp:"
|
|
373
|
+
display_check " Valid", result.timestamp_valid?
|
|
374
|
+
say " Time: #{result.timestamp&.iso8601}" if result.timestamp
|
|
375
|
+
say " Authority: #{result.timestamp_authority}" if result.timestamp_authority
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
unless result.errors.empty?
|
|
379
|
+
say ""
|
|
380
|
+
say "Errors:", :red
|
|
381
|
+
result.errors.each { |e| say " - #{e}", :red }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
unless result.warnings.empty?
|
|
385
|
+
say ""
|
|
386
|
+
say "Warnings:", :yellow
|
|
387
|
+
result.warnings.each { |w| say " - #{w}", :yellow }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def display_check(name, passed)
|
|
392
|
+
if passed
|
|
393
|
+
say "#{name}: ✓", :green
|
|
394
|
+
else
|
|
395
|
+
say "#{name}: ✗", :red
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def display_gem_signature_info(signature)
|
|
400
|
+
say "Signature files:"
|
|
401
|
+
signature.each_key do |name|
|
|
402
|
+
say " - #{name}"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def display_zip_signature_info(signature)
|
|
407
|
+
say "META-INF contents:"
|
|
408
|
+
say " - MANIFEST.MF: #{signature[:manifest] ? 'present' : 'missing'}"
|
|
409
|
+
say " - CERT.SF: #{signature[:signature_file] ? 'present' : 'missing'}"
|
|
410
|
+
say " - CERT.RSA: #{signature[:signature_block] ? 'present' : 'missing'}"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def display_pdf_signature_info(signature)
|
|
414
|
+
say "PDF Signature:"
|
|
415
|
+
say " - SubFilter: #{signature[:sub_filter] || 'unknown'}"
|
|
416
|
+
say " - ByteRange: #{signature[:byte_range]&.inspect || 'unknown'}"
|
|
417
|
+
say " - Name: #{signature[:name]}" if signature[:name]
|
|
418
|
+
say " - Reason: #{signature[:reason]}" if signature[:reason]
|
|
419
|
+
say " - Location: #{signature[:location]}" if signature[:location]
|
|
420
|
+
say " - Signing Time: #{signature[:signing_time]}" if signature[:signing_time]
|
|
421
|
+
say " - Contact: #{signature[:contact_info]}" if signature[:contact_info]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def say_error(message)
|
|
425
|
+
say "Error: #{message}", :red
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyCodeSign
|
|
4
|
+
# Global configuration for EasyCodeSign
|
|
5
|
+
#
|
|
6
|
+
# @example Configure the gem
|
|
7
|
+
# EasyCodeSign.configure do |config|
|
|
8
|
+
# config.provider = :safenet
|
|
9
|
+
# config.pkcs11_library = '/usr/local/lib/libeToken.dylib'
|
|
10
|
+
# config.timestamp_authority = 'http://timestamp.digicert.com'
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
class Configuration
|
|
14
|
+
# Token provider type (:safenet, :yubikey, etc.)
|
|
15
|
+
# @return [Symbol]
|
|
16
|
+
attr_accessor :provider
|
|
17
|
+
|
|
18
|
+
# Path to the PKCS#11 library for the hardware token
|
|
19
|
+
# @return [String, nil]
|
|
20
|
+
attr_accessor :pkcs11_library
|
|
21
|
+
|
|
22
|
+
# Token slot index (default: 0)
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
attr_accessor :slot_index
|
|
25
|
+
|
|
26
|
+
# RFC 3161 Timestamp Authority URL
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
attr_accessor :timestamp_authority
|
|
29
|
+
|
|
30
|
+
# Hash algorithm for timestamping (:sha256, :sha384, :sha512)
|
|
31
|
+
# @return [Symbol]
|
|
32
|
+
attr_accessor :timestamp_hash_algorithm
|
|
33
|
+
|
|
34
|
+
# Whether to require timestamping for all signatures
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
attr_accessor :require_timestamp
|
|
37
|
+
|
|
38
|
+
# Custom trust store path for verification
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
attr_accessor :trust_store_path
|
|
41
|
+
|
|
42
|
+
# Whether to check certificate revocation during verification
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
attr_accessor :check_revocation
|
|
45
|
+
|
|
46
|
+
# Timeout for network operations (TSA, OCSP) in seconds
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
attr_accessor :network_timeout
|
|
49
|
+
|
|
50
|
+
# Logger instance for debugging
|
|
51
|
+
# @return [Logger, nil]
|
|
52
|
+
attr_accessor :logger
|
|
53
|
+
|
|
54
|
+
# Callback for PIN entry (receives slot_info, returns PIN string)
|
|
55
|
+
# @return [Proc, nil]
|
|
56
|
+
attr_accessor :pin_callback
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
@provider = :safenet
|
|
60
|
+
@pkcs11_library = default_pkcs11_library
|
|
61
|
+
@slot_index = 0
|
|
62
|
+
@timestamp_authority = ENV["EASYSIGN_TSA_URL"]
|
|
63
|
+
@timestamp_hash_algorithm = :sha256
|
|
64
|
+
@require_timestamp = false
|
|
65
|
+
@trust_store_path = nil
|
|
66
|
+
@check_revocation = true
|
|
67
|
+
@network_timeout = 30
|
|
68
|
+
@logger = nil
|
|
69
|
+
@pin_callback = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validates the configuration
|
|
73
|
+
# @raise [ConfigurationError] if configuration is invalid
|
|
74
|
+
def validate!
|
|
75
|
+
raise ConfigurationError, "Provider must be specified" if provider.nil?
|
|
76
|
+
raise ConfigurationError, "PKCS#11 library path must be specified" if pkcs11_library.nil?
|
|
77
|
+
|
|
78
|
+
unless File.exist?(pkcs11_library)
|
|
79
|
+
raise ConfigurationError, "PKCS#11 library not found: #{pkcs11_library}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if require_timestamp && timestamp_authority.nil?
|
|
83
|
+
raise ConfigurationError, "Timestamp authority URL required when require_timestamp is true"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def default_pkcs11_library
|
|
92
|
+
case RUBY_PLATFORM
|
|
93
|
+
when /darwin/
|
|
94
|
+
"/usr/local/lib/libeToken.dylib"
|
|
95
|
+
when /linux/
|
|
96
|
+
"/usr/lib/libeToken.so"
|
|
97
|
+
when /mswin|mingw/
|
|
98
|
+
"C:\\Windows\\System32\\eToken.dll"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module EasyCodeSign
|
|
7
|
+
# Serializable data object returned by Phase 1 of deferred PDF signing.
|
|
8
|
+
#
|
|
9
|
+
# Contains the digest computed from the PDF's ByteRange content, along with
|
|
10
|
+
# all metadata needed to finalize the signature in Phase 2.
|
|
11
|
+
#
|
|
12
|
+
# @example Prepare and serialize
|
|
13
|
+
# request = EasyCodeSign.prepare_pdf("doc.pdf", pin: "1234")
|
|
14
|
+
# json = request.to_h.to_json
|
|
15
|
+
#
|
|
16
|
+
# @example Deserialize and finalize
|
|
17
|
+
# restored = DeferredSigningRequest.from_h(JSON.parse(json))
|
|
18
|
+
# EasyCodeSign.finalize_pdf(restored, raw_signature)
|
|
19
|
+
#
|
|
20
|
+
class DeferredSigningRequest
|
|
21
|
+
attr_reader :digest,
|
|
22
|
+
:digest_algorithm,
|
|
23
|
+
:prepared_pdf_path,
|
|
24
|
+
:byte_range,
|
|
25
|
+
:certificate,
|
|
26
|
+
:certificate_chain,
|
|
27
|
+
:estimated_size,
|
|
28
|
+
:signing_time,
|
|
29
|
+
:created_at,
|
|
30
|
+
:signed_attributes_data
|
|
31
|
+
|
|
32
|
+
def initialize(digest:, digest_algorithm:, prepared_pdf_path:, byte_range:,
|
|
33
|
+
certificate:, certificate_chain:, estimated_size:,
|
|
34
|
+
signing_time:, created_at: Time.now, signed_attributes_data: nil)
|
|
35
|
+
@digest = digest
|
|
36
|
+
@digest_algorithm = digest_algorithm.to_sym
|
|
37
|
+
@prepared_pdf_path = prepared_pdf_path
|
|
38
|
+
@byte_range = byte_range
|
|
39
|
+
@certificate = certificate
|
|
40
|
+
@certificate_chain = certificate_chain
|
|
41
|
+
@estimated_size = estimated_size
|
|
42
|
+
@signing_time = signing_time
|
|
43
|
+
@created_at = created_at
|
|
44
|
+
@signed_attributes_data = signed_attributes_data
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Hex-encoded digest for display and CLI output
|
|
48
|
+
# @return [String]
|
|
49
|
+
def digest_hex
|
|
50
|
+
digest.unpack1("H*")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Base64-encoded digest for WebCrypto / Fortify consumption
|
|
54
|
+
# @return [String]
|
|
55
|
+
def digest_base64
|
|
56
|
+
Base64.strict_encode64(digest)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Base64-encoded signed attributes DER for WebCrypto hash-and-sign.
|
|
60
|
+
# Use this instead of digest_base64 when the signer hashes internally
|
|
61
|
+
# (e.g. crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, data)).
|
|
62
|
+
# @return [String, nil]
|
|
63
|
+
def signed_attributes_base64
|
|
64
|
+
signed_attributes_data && Base64.strict_encode64(signed_attributes_data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Serialize to a Hash suitable for JSON transport.
|
|
68
|
+
# Binary fields are Base64-encoded, certificates are PEM-encoded.
|
|
69
|
+
# @return [Hash]
|
|
70
|
+
def to_h
|
|
71
|
+
h = {
|
|
72
|
+
"digest" => digest_base64,
|
|
73
|
+
"digest_algorithm" => digest_algorithm.to_s,
|
|
74
|
+
"prepared_pdf_path" => prepared_pdf_path,
|
|
75
|
+
"byte_range" => byte_range,
|
|
76
|
+
"certificate" => certificate.to_pem,
|
|
77
|
+
"certificate_chain" => certificate_chain.map(&:to_pem),
|
|
78
|
+
"estimated_size" => estimated_size,
|
|
79
|
+
"signing_time" => signing_time.iso8601,
|
|
80
|
+
"created_at" => created_at.iso8601
|
|
81
|
+
}
|
|
82
|
+
h["signed_attributes_data"] = signed_attributes_base64 if signed_attributes_data
|
|
83
|
+
h
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Deserialize from a Hash (as produced by #to_h / JSON.parse).
|
|
87
|
+
# @param hash [Hash] serialized request
|
|
88
|
+
# @return [DeferredSigningRequest]
|
|
89
|
+
def self.from_h(hash)
|
|
90
|
+
new(
|
|
91
|
+
digest: Base64.strict_decode64(hash["digest"]),
|
|
92
|
+
digest_algorithm: hash["digest_algorithm"].to_sym,
|
|
93
|
+
prepared_pdf_path: hash["prepared_pdf_path"],
|
|
94
|
+
byte_range: hash["byte_range"],
|
|
95
|
+
certificate: OpenSSL::X509::Certificate.new(hash["certificate"]),
|
|
96
|
+
certificate_chain: hash["certificate_chain"].map { |pem| OpenSSL::X509::Certificate.new(pem) },
|
|
97
|
+
estimated_size: hash["estimated_size"],
|
|
98
|
+
signing_time: Time.parse(hash["signing_time"]),
|
|
99
|
+
created_at: Time.parse(hash["created_at"]),
|
|
100
|
+
signed_attributes_data: hash["signed_attributes_data"] && Base64.strict_decode64(hash["signed_attributes_data"])
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|