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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module EasyCodeSign
6
+ module Verification
7
+ # Manages trusted root certificates for verification
8
+ #
9
+ # By default, uses the system's trusted CA certificates.
10
+ # Additional certificates can be added for custom PKI.
11
+ #
12
+ # @example Using system trust store
13
+ # store = TrustStore.new
14
+ # store.trusted?(certificate, chain)
15
+ #
16
+ # @example Adding custom trusted certificate
17
+ # store = TrustStore.new
18
+ # store.add_certificate(my_root_ca)
19
+ # store.add_file("/path/to/custom_ca.pem")
20
+ #
21
+ class TrustStore
22
+ attr_reader :store
23
+
24
+ def initialize(use_system_certs: true)
25
+ @store = OpenSSL::X509::Store.new
26
+ @store.set_default_paths if use_system_certs
27
+ @custom_certs = []
28
+ end
29
+
30
+ # Add a trusted certificate
31
+ # @param cert [OpenSSL::X509::Certificate]
32
+ # @return [self]
33
+ def add_certificate(cert)
34
+ @store.add_cert(cert)
35
+ @custom_certs << cert
36
+ self
37
+ rescue OpenSSL::X509::StoreError => e
38
+ # Certificate might already be in store
39
+ raise unless e.message.include?("cert already in hash table")
40
+
41
+ self
42
+ end
43
+
44
+ # Add certificates from a PEM file
45
+ # @param path [String] path to PEM file
46
+ # @return [self]
47
+ def add_file(path)
48
+ content = File.read(path)
49
+ certs = extract_certificates(content)
50
+ certs.each { |cert| add_certificate(cert) }
51
+ self
52
+ end
53
+
54
+ # Add certificates from a directory
55
+ # @param path [String] path to directory containing PEM files
56
+ # @return [self]
57
+ def add_directory(path)
58
+ Dir.glob(File.join(path, "*.pem")).each do |file|
59
+ add_file(file)
60
+ end
61
+ self
62
+ end
63
+
64
+ # Check if a certificate is trusted
65
+ # @param cert [OpenSSL::X509::Certificate] certificate to verify
66
+ # @param chain [Array<OpenSSL::X509::Certificate>] intermediate certificates
67
+ # @return [Boolean]
68
+ def trusted?(cert, chain = [])
69
+ @store.verify(cert, chain)
70
+ end
71
+
72
+ # Verify and return detailed error if not trusted
73
+ # @param cert [OpenSSL::X509::Certificate]
74
+ # @param chain [Array<OpenSSL::X509::Certificate>]
75
+ # @return [Hash] { trusted: Boolean, error: String|nil }
76
+ def verify(cert, chain = [])
77
+ if @store.verify(cert, chain)
78
+ { trusted: true, error: nil }
79
+ else
80
+ { trusted: false, error: @store.error_string }
81
+ end
82
+ end
83
+
84
+ # Get the verification error string from last verify call
85
+ # @return [String, nil]
86
+ def error_string
87
+ @store.error_string
88
+ end
89
+
90
+ # Set verification time (for testing signatures against past time)
91
+ # @param time [Time]
92
+ # @return [self]
93
+ def at_time(time)
94
+ @store.time = time
95
+ self
96
+ end
97
+
98
+ # Enable/disable CRL checking
99
+ # @param enabled [Boolean]
100
+ # @return [self]
101
+ def check_crl(enabled = true)
102
+ if enabled
103
+ @store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK |
104
+ OpenSSL::X509::V_FLAG_CRL_CHECK_ALL
105
+ else
106
+ @store.flags = 0
107
+ end
108
+ self
109
+ end
110
+
111
+ # Add a CRL for revocation checking
112
+ # @param crl [OpenSSL::X509::CRL]
113
+ # @return [self]
114
+ def add_crl(crl)
115
+ @store.add_crl(crl)
116
+ self
117
+ end
118
+
119
+ # Load CRL from file
120
+ # @param path [String] path to CRL file (PEM or DER)
121
+ # @return [self]
122
+ def add_crl_file(path)
123
+ content = File.read(path)
124
+ crl = OpenSSL::X509::CRL.new(content)
125
+ add_crl(crl)
126
+ self
127
+ end
128
+
129
+ private
130
+
131
+ def extract_certificates(pem_content)
132
+ certs = []
133
+ pem_content.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m).each do |pem|
134
+ certs << OpenSSL::X509::Certificate.new(pem)
135
+ end
136
+ certs
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ # Main verifier for signed files
5
+ #
6
+ # Orchestrates signature verification including:
7
+ # - Extracting signatures from files
8
+ # - Verifying cryptographic signatures
9
+ # - Validating certificate chains
10
+ # - Checking timestamps
11
+ #
12
+ # @example Verify a signed gem
13
+ # verifier = EasyCodeSign::Verifier.new
14
+ # result = verifier.verify("signed.gem")
15
+ # puts result.valid? ? "Valid!" : result.errors
16
+ #
17
+ # @example Verify with custom trust store
18
+ # trust_store = EasyCodeSign::Verification::TrustStore.new
19
+ # trust_store.add_file("/path/to/custom_ca.pem")
20
+ # verifier = EasyCodeSign::Verifier.new(trust_store: trust_store)
21
+ # result = verifier.verify("signed.gem")
22
+ #
23
+ class Verifier
24
+ attr_reader :trust_store, :configuration
25
+
26
+ def initialize(trust_store: nil, configuration: nil, check_revocation: nil)
27
+ @configuration = configuration || EasyCodeSign.configuration
28
+ @trust_store = trust_store || build_trust_store
29
+ @check_revocation = check_revocation.nil? ? @configuration.check_revocation : check_revocation
30
+ end
31
+
32
+ # Verify a signed file
33
+ #
34
+ # @param file_path [String] path to the signed file
35
+ # @param check_timestamp [Boolean] whether to verify timestamp
36
+ # @return [Verification::Result]
37
+ #
38
+ def verify(file_path, check_timestamp: true)
39
+ result = Verification::Result.new
40
+ result.file_path = file_path
41
+
42
+ begin
43
+ # Determine file type and create appropriate handler
44
+ signable = create_signable(file_path)
45
+ result.file_type = signable.class.name.split("::").last.downcase.to_sym
46
+
47
+ # Extract signature from file
48
+ signature_data = signable.extract_signature
49
+ unless signature_data
50
+ result.add_error("File is not signed")
51
+ return result
52
+ end
53
+
54
+ # Verify based on file type
55
+ case result.file_type
56
+ when :gemfile
57
+ verify_gem(signable, signature_data, result)
58
+ when :zipfile
59
+ verify_zip(signable, signature_data, result)
60
+ when :pdffile
61
+ verify_pdf(signable, signature_data, result)
62
+ else
63
+ result.add_error("Unsupported file type for verification")
64
+ return result
65
+ end
66
+
67
+ # Verify timestamp if present and requested
68
+ if check_timestamp && result.timestamped
69
+ verify_timestamp_token(signature_data, result)
70
+ end
71
+
72
+ # Determine overall validity
73
+ result.valid = result.signature_valid &&
74
+ result.integrity_valid &&
75
+ result.certificate_valid &&
76
+ result.chain_valid &&
77
+ result.trusted
78
+
79
+ rescue InvalidFileError => e
80
+ result.add_error("Invalid file: #{e.message}")
81
+ rescue StandardError => e
82
+ result.add_error("Verification error: #{e.message}")
83
+ end
84
+
85
+ result
86
+ end
87
+
88
+ # Verify multiple files
89
+ #
90
+ # @param file_paths [Array<String>]
91
+ # @return [Hash<String, Verification::Result>]
92
+ #
93
+ def verify_batch(file_paths)
94
+ file_paths.to_h { |path| [path, verify(path)] }
95
+ end
96
+
97
+ private
98
+
99
+ def build_trust_store
100
+ store = Verification::TrustStore.new(use_system_certs: true)
101
+
102
+ # Add custom trust store if configured
103
+ if @configuration.trust_store_path
104
+ if File.directory?(@configuration.trust_store_path)
105
+ store.add_directory(@configuration.trust_store_path)
106
+ elsif File.file?(@configuration.trust_store_path)
107
+ store.add_file(@configuration.trust_store_path)
108
+ end
109
+ end
110
+
111
+ store
112
+ end
113
+
114
+ def create_signable(file_path)
115
+ extension = File.extname(file_path).downcase
116
+
117
+ case extension
118
+ when ".gem"
119
+ Signable::GemFile.new(file_path)
120
+ when ".zip", ".jar", ".apk", ".war", ".ear"
121
+ Signable::ZipFile.new(file_path)
122
+ when ".pdf"
123
+ Signable::PdfFile.new(file_path)
124
+ else
125
+ raise InvalidFileError, "Unsupported file type: #{extension}"
126
+ end
127
+ end
128
+
129
+ def verify_gem(signable, signature_data, result)
130
+ # Gem signatures are in .sig files
131
+ signature_checker = Verification::SignatureChecker.new
132
+
133
+ # Get the original content and signature
134
+ signable.prepare_for_signing
135
+ content = signable.content_to_sign
136
+
137
+ # Find the primary signature file (checksums.yaml.gz.sig)
138
+ sig_file = signature_data["checksums.yaml.gz.sig"] || signature_data.values.first
139
+
140
+ unless sig_file
141
+ result.add_error("No signature found in gem")
142
+ return
143
+ end
144
+
145
+ # Verify PKCS#7 signature
146
+ sig_result = signature_checker.verify_detached_pkcs7(sig_file, content, trust_store)
147
+
148
+ result.signature_valid = sig_result.signature_valid
149
+ result.signer_certificate = sig_result.signer_certificate
150
+ result.certificate_chain = sig_result.certificates
151
+ result.signature_algorithm = sig_result.signature_algorithm
152
+
153
+ sig_result.errors.each { |e| result.add_error(e) }
154
+
155
+ if result.signer_certificate
156
+ extract_signer_info(result)
157
+ verify_certificate_chain(result)
158
+ end
159
+
160
+ # Verify file integrity
161
+ verify_gem_integrity(signable, signature_data, result)
162
+ end
163
+
164
+ def verify_zip(signable, signature_data, result)
165
+ signature_checker = Verification::SignatureChecker.new
166
+
167
+ # ZIP uses JAR-style signatures in META-INF/
168
+ manifest = signature_data[:manifest]
169
+ signature_file = signature_data[:signature_file]
170
+ signature_block = signature_data[:signature_block]
171
+
172
+ unless manifest && signature_file && signature_block
173
+ result.add_error("Incomplete JAR signature (missing META-INF files)")
174
+ return
175
+ end
176
+
177
+ # Verify PKCS#7 signature over the .SF file
178
+ sig_result = signature_checker.verify_detached_pkcs7(signature_block, signature_file, trust_store)
179
+
180
+ result.signature_valid = sig_result.signature_valid
181
+ result.signer_certificate = sig_result.signer_certificate
182
+ result.certificate_chain = sig_result.certificates
183
+ result.signature_algorithm = sig_result.signature_algorithm
184
+
185
+ sig_result.errors.each { |e| result.add_error(e) }
186
+
187
+ if result.signer_certificate
188
+ extract_signer_info(result)
189
+ verify_certificate_chain(result)
190
+ end
191
+
192
+ # Verify manifest integrity
193
+ verify_zip_manifest(signable, manifest, signature_file, result)
194
+ end
195
+
196
+ def verify_pdf(signable, signature_data, result)
197
+ signature_checker = Verification::SignatureChecker.new
198
+
199
+ # PDF signatures store PKCS#7 in /Contents and signed data via ByteRange
200
+ contents = signature_data[:contents]
201
+ byte_range = signature_data[:byte_range]
202
+
203
+ unless contents && byte_range
204
+ result.add_error("Invalid PDF signature (missing Contents or ByteRange)")
205
+ return
206
+ end
207
+
208
+ # Read the ByteRange content from the file
209
+ signed_content = read_byte_range_content(signable.file_path, byte_range)
210
+
211
+ # Verify PKCS#7 signature
212
+ sig_result = signature_checker.verify_pkcs7(contents, signed_content, trust_store)
213
+
214
+ result.signature_valid = sig_result.signature_valid
215
+ result.signer_certificate = sig_result.signer_certificate
216
+ result.certificate_chain = sig_result.certificates
217
+ result.signature_algorithm = sig_result.signature_algorithm
218
+
219
+ sig_result.errors.each { |e| result.add_error(e) }
220
+
221
+ if result.signer_certificate
222
+ extract_signer_info(result)
223
+ verify_certificate_chain(result)
224
+ end
225
+
226
+ # Verify PDF integrity (ByteRange covers the document properly)
227
+ verify_pdf_integrity(signable, signature_data, result)
228
+
229
+ # Extract signer info from PDF signature dict
230
+ extract_pdf_signature_info(signature_data, result)
231
+ end
232
+
233
+ def read_byte_range_content(file_path, byte_range)
234
+ # ByteRange = [offset1, length1, offset2, length2]
235
+ # The signed content is: bytes[offset1..offset1+length1] + bytes[offset2..offset2+length2]
236
+ File.open(file_path, "rb") do |f|
237
+ f.seek(byte_range[0])
238
+ part1 = f.read(byte_range[1])
239
+ f.seek(byte_range[2])
240
+ part2 = f.read(byte_range[3])
241
+ part1 + part2
242
+ end
243
+ end
244
+
245
+ def verify_pdf_integrity(signable, signature_data, result)
246
+ result.integrity_valid = true
247
+
248
+ byte_range = signature_data[:byte_range]
249
+ return unless byte_range
250
+
251
+ file_size = File.size(signable.file_path)
252
+
253
+ # Verify ByteRange makes sense:
254
+ # [0, before_sig_offset, after_sig_offset, rest_of_file]
255
+ # The end should match file size
256
+ expected_end = byte_range[2] + byte_range[3]
257
+
258
+ unless expected_end == file_size
259
+ result.integrity_valid = false
260
+ result.add_error("PDF ByteRange does not cover entire document (expected #{file_size}, got #{expected_end})")
261
+ end
262
+
263
+ # Verify no gap exists (signature placeholder should be between the two ranges)
264
+ signature_start = byte_range[0] + byte_range[1]
265
+ signature_end = byte_range[2]
266
+
267
+ if signature_start > signature_end
268
+ result.integrity_valid = false
269
+ result.add_error("Invalid PDF ByteRange (overlapping ranges)")
270
+ end
271
+ end
272
+
273
+ def extract_pdf_signature_info(signature_data, result)
274
+ # Extract additional info from PDF signature dictionary
275
+ result.signature_reason = signature_data[:reason] if signature_data[:reason]
276
+ result.signature_location = signature_data[:location] if signature_data[:location]
277
+
278
+ # Check for timestamp
279
+ # This would require parsing the unsigned attributes of the PKCS#7
280
+ # For now, we don't extract embedded timestamps from PDF
281
+ end
282
+
283
+ def verify_certificate_chain(result)
284
+ return unless result.signer_certificate
285
+
286
+ chain_validator = Verification::CertificateChain.new(
287
+ trust_store,
288
+ check_revocation: @check_revocation,
289
+ network_timeout: @configuration.network_timeout
290
+ )
291
+
292
+ # Use timestamp time if available for point-in-time verification
293
+ at_time = result.timestamped? ? result.timestamp : nil
294
+
295
+ chain_result = chain_validator.validate(
296
+ result.signer_certificate,
297
+ result.certificate_chain[1..] || [],
298
+ at_time: at_time
299
+ )
300
+
301
+ result.certificate_valid = chain_result.certificate_valid
302
+ result.chain_valid = chain_result.chain_valid
303
+ result.trusted = chain_result.trusted
304
+ result.not_revoked = chain_result.not_revoked
305
+ result.revocation_checked = chain_result.revocation_checked
306
+
307
+ chain_result.errors.each { |e| result.add_error(e) }
308
+ chain_result.warnings.each { |w| result.add_warning(w) }
309
+ end
310
+
311
+ def extract_signer_info(result)
312
+ cert = result.signer_certificate
313
+ return unless cert
314
+
315
+ subject = cert.subject
316
+
317
+ # Extract CN
318
+ cn = subject.to_a.find { |name, _, _| name == "CN" }
319
+ result.signer_name = cn ? cn[1] : subject.to_s
320
+
321
+ # Extract O
322
+ org = subject.to_a.find { |name, _, _| name == "O" }
323
+ result.signer_organization = org ? org[1] : nil
324
+ end
325
+
326
+ def verify_gem_integrity(signable, signature_data, result)
327
+ # For gems, verify all signed files have valid signatures
328
+ # and content matches
329
+ result.integrity_valid = true
330
+
331
+ # The signature covers the gem contents hash
332
+ # If signature verification passed, integrity is verified
333
+ # Additional checks could verify individual file hashes
334
+ end
335
+
336
+ def verify_zip_manifest(signable, manifest, signature_file, result)
337
+ result.integrity_valid = true
338
+
339
+ # Parse manifest and verify file hashes
340
+ begin
341
+ # Verify that .SF file hash matches manifest
342
+ sf_manifest_digest = extract_sf_manifest_digest(signature_file)
343
+ actual_manifest_hash = compute_manifest_hash(manifest)
344
+
345
+ unless sf_manifest_digest == actual_manifest_hash
346
+ result.integrity_valid = false
347
+ result.add_error("Manifest digest mismatch")
348
+ return
349
+ end
350
+
351
+ # Verify individual file hashes from manifest
352
+ verify_zip_file_hashes(signable, manifest, result)
353
+
354
+ rescue StandardError => e
355
+ result.integrity_valid = false
356
+ result.add_error("Manifest verification failed: #{e.message}")
357
+ end
358
+ end
359
+
360
+ def extract_sf_manifest_digest(signature_file)
361
+ # Look for SHA-256-Digest-Manifest or similar
362
+ match = signature_file.match(/SHA-256-Digest-Manifest:\s*(\S+)/) ||
363
+ signature_file.match(/SHA-384-Digest-Manifest:\s*(\S+)/) ||
364
+ signature_file.match(/SHA-512-Digest-Manifest:\s*(\S+)/)
365
+
366
+ match ? match[1] : nil
367
+ end
368
+
369
+ def compute_manifest_hash(manifest)
370
+ require "base64"
371
+ Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(manifest))
372
+ end
373
+
374
+ def verify_zip_file_hashes(signable, manifest, result)
375
+ # Parse manifest entries
376
+ entries = parse_manifest(manifest)
377
+
378
+ ::Zip::File.open(signable.file_path) do |zip|
379
+ entries.each do |name, expected_hash|
380
+ entry = zip.find_entry(name)
381
+ unless entry
382
+ result.add_warning("File in manifest not found in ZIP: #{name}")
383
+ next
384
+ end
385
+
386
+ actual_hash = compute_file_hash(entry.get_input_stream.read)
387
+ unless actual_hash == expected_hash
388
+ result.integrity_valid = false
389
+ result.add_error("Hash mismatch for file: #{name}")
390
+ end
391
+ end
392
+ end
393
+ end
394
+
395
+ def parse_manifest(manifest)
396
+ entries = {}
397
+ current_name = nil
398
+
399
+ manifest.each_line do |line|
400
+ line = line.strip
401
+ if line.start_with?("Name: ")
402
+ current_name = line.sub("Name: ", "")
403
+ elsif current_name && line.match?(/SHA-\d+-Digest:/)
404
+ hash = line.split(": ", 2).last
405
+ entries[current_name] = hash
406
+ end
407
+ end
408
+
409
+ entries
410
+ end
411
+
412
+ def compute_file_hash(content)
413
+ require "base64"
414
+ Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(content))
415
+ end
416
+
417
+ def verify_timestamp_token(signature_data, result)
418
+ # Look for embedded timestamp in PKCS#7
419
+ # This would require parsing the unsigned attributes
420
+ # For now, mark as not verified if timestamp detection is needed
421
+
422
+ result.timestamp_valid = false
423
+ result.add_warning("Timestamp verification not yet implemented")
424
+ end
425
+ end
426
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ VERSION = "0.1.0"
5
+ end