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