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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ # Base error class for all EasyCodeSign errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration is invalid or incomplete
8
+ class ConfigurationError < Error; end
9
+
10
+ # Base class for all token-related errors
11
+ class TokenError < Error; end
12
+
13
+ # Raised when the hardware token is not found or not connected
14
+ class TokenNotFoundError < TokenError; end
15
+
16
+ # Raised when PIN entry fails (wrong PIN, locked, etc.)
17
+ class PinError < TokenError
18
+ attr_reader :retries_remaining
19
+
20
+ def initialize(message = "PIN verification failed", retries_remaining: nil)
21
+ @retries_remaining = retries_remaining
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ # Raised when the token is locked due to too many failed PIN attempts
27
+ class TokenLockedError < TokenError; end
28
+
29
+ # Raised when the requested certificate/key is not found on the token
30
+ class KeyNotFoundError < TokenError; end
31
+
32
+ # Raised when a PKCS#11 operation fails
33
+ class Pkcs11Error < TokenError
34
+ attr_reader :pkcs11_error_code
35
+
36
+ def initialize(message, pkcs11_error_code: nil)
37
+ @pkcs11_error_code = pkcs11_error_code
38
+ super(message)
39
+ end
40
+ end
41
+
42
+ # Base class for signing-related errors
43
+ class SigningError < Error; end
44
+
45
+ # Raised when the file to be signed cannot be read or is invalid
46
+ class InvalidFileError < SigningError; end
47
+
48
+ # Raised when signature generation fails
49
+ class SignatureGenerationError < SigningError; end
50
+
51
+ # Base class for verification-related errors
52
+ class VerificationError < Error; end
53
+
54
+ # Raised when signature verification fails cryptographically
55
+ class InvalidSignatureError < VerificationError; end
56
+
57
+ # Raised when the signed file has been tampered with
58
+ class TamperedFileError < VerificationError; end
59
+
60
+ # Raised when certificate chain validation fails
61
+ class CertificateChainError < VerificationError
62
+ attr_reader :certificate, :reason
63
+
64
+ def initialize(message, certificate: nil, reason: nil)
65
+ @certificate = certificate
66
+ @reason = reason
67
+ super(message)
68
+ end
69
+ end
70
+
71
+ # Raised when a certificate has been revoked
72
+ class CertificateRevokedError < CertificateChainError; end
73
+
74
+ # Raised when a certificate has expired
75
+ class CertificateExpiredError < CertificateChainError; end
76
+
77
+ # Raised when the signing certificate is not trusted
78
+ class UntrustedCertificateError < CertificateChainError; end
79
+
80
+ # Base class for timestamp-related errors
81
+ class TimestampError < Error; end
82
+
83
+ # Raised when communication with the TSA fails
84
+ class TimestampAuthorityError < TimestampError
85
+ attr_reader :http_status
86
+
87
+ def initialize(message, http_status: nil)
88
+ @http_status = http_status
89
+ super(message)
90
+ end
91
+ end
92
+
93
+ # Raised when the TSA response is invalid or verification fails
94
+ class InvalidTimestampError < TimestampError; end
95
+
96
+ # Raised when a required timestamp is missing
97
+ class MissingTimestampError < TimestampError; end
98
+
99
+ # Base class for PDF-related errors
100
+ class PdfError < SigningError; end
101
+
102
+ # Raised when the PDF file is invalid or cannot be parsed
103
+ class InvalidPdfError < PdfError; end
104
+
105
+ # Raised when PDF signature operations fail
106
+ class PdfSignatureError < PdfError; end
107
+
108
+ # Raised when ByteRange calculation or verification fails
109
+ class ByteRangeError < PdfError; end
110
+
111
+ # Raised when deferred (two-phase) PDF signing fails
112
+ class DeferredSigningError < PdfError; end
113
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ module Pdf
5
+ # Builds visible signature appearance for PDF signatures
6
+ #
7
+ # Creates an appearance stream that displays signature information
8
+ # in the PDF document (signer name, date, reason, etc.)
9
+ #
10
+ class AppearanceBuilder
11
+ POSITIONS = {
12
+ top_left: ->(box) { [36, box.height - 36 - 50, 236, box.height - 36] },
13
+ top_right: ->(box) { [box.width - 236, box.height - 36 - 50, box.width - 36, box.height - 36] },
14
+ bottom_left: ->(box) { [36, 36, 236, 86] },
15
+ bottom_right: ->(box) { [box.width - 236, 36, box.width - 36, 86] }
16
+ }.freeze
17
+
18
+ def initialize(document, config, certificate)
19
+ @document = document
20
+ @config = config
21
+ @certificate = certificate
22
+ end
23
+
24
+ # Build appearance stream for visible signature
25
+ # @param widget [HexaPDF::Type::Annotations::Widget] the signature widget
26
+ # @return [void]
27
+ def build_appearance(widget)
28
+ rect = widget[:Rect].value
29
+ width = rect[2] - rect[0]
30
+ height = rect[3] - rect[1]
31
+
32
+ # Create appearance form XObject
33
+ form = @document.add({ Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height] })
34
+ canvas = form.canvas
35
+
36
+ # Draw border
37
+ canvas.stroke_color(0, 0, 0)
38
+ canvas.line_width(1)
39
+ canvas.rectangle(0.5, 0.5, width - 1, height - 1)
40
+ canvas.stroke
41
+
42
+ # Draw text content
43
+ draw_signature_text(canvas, width, height)
44
+
45
+ # Set as widget's normal appearance
46
+ widget[:AP] = { N: form }
47
+ end
48
+
49
+ # Calculate signature rectangle for a given position
50
+ # @param page [HexaPDF::Type::Page] the page
51
+ # @param position [Symbol] position preset
52
+ # @return [Array<Numeric>] rectangle coordinates [x1, y1, x2, y2]
53
+ def self.calculate_rect(page, position, custom_rect: nil)
54
+ return custom_rect if custom_rect
55
+
56
+ box = page.box(:media)
57
+ calculator = POSITIONS[position.to_sym] || POSITIONS[:bottom_right]
58
+ calculator.call(box)
59
+ end
60
+
61
+ private
62
+
63
+ def draw_signature_text(canvas, width, height)
64
+ canvas.font("Helvetica", size: 8)
65
+ canvas.fill_color(0, 0, 0)
66
+
67
+ y_offset = height - 12
68
+ x_offset = 5
69
+
70
+ # Signer name
71
+ signer = extract_signer_name
72
+ canvas.text("Digitally signed by:", at: [x_offset, y_offset])
73
+ y_offset -= 10
74
+ canvas.text(signer, at: [x_offset, y_offset])
75
+ y_offset -= 12
76
+
77
+ # Reason (if provided)
78
+ if @config[:reason]
79
+ canvas.text("Reason: #{@config[:reason]}", at: [x_offset, y_offset])
80
+ y_offset -= 10
81
+ end
82
+
83
+ # Location (if provided)
84
+ if @config[:location]
85
+ canvas.text("Location: #{@config[:location]}", at: [x_offset, y_offset])
86
+ y_offset -= 10
87
+ end
88
+
89
+ # Date
90
+ canvas.text("Date: #{Time.now.strftime('%Y-%m-%d %H:%M:%S %Z')}", at: [x_offset, y_offset])
91
+ end
92
+
93
+ def extract_signer_name
94
+ # Try to extract CN from certificate subject
95
+ subject = @certificate.subject.to_a
96
+ cn = subject.find { |name, _, _| name == "CN" }
97
+ return cn[1] if cn
98
+
99
+ # Fallback to full subject
100
+ @certificate.subject.to_s
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ module Pdf
5
+ # Adapts a pre-fetched timestamp token for HexaPDF's SignedDataCreator.
6
+ #
7
+ # HexaPDF's SignedDataCreator calls `timestamp_handler.sign(io, byte_range)`
8
+ # and embeds the return value as the id-aa-timeStampToken unsigned attribute.
9
+ # This handler returns the DER bytes of an already-obtained token rather than
10
+ # making a live TSA request.
11
+ #
12
+ # Supports both eager (TimestampToken) and lazy (Proc) modes:
13
+ # - Eager: TimestampHandler.new(token) — token already available
14
+ # - Lazy: TimestampHandler.new(-> { token }) — token resolved at sign time
15
+ #
16
+ class TimestampHandler
17
+ def initialize(timestamp_token)
18
+ @timestamp_token = timestamp_token
19
+ end
20
+
21
+ # Called by HexaPDF's SignedDataCreator#create_unsigned_attrs
22
+ # @param _io [IO] ignored — we already have the token
23
+ # @param _byte_range [Array] ignored
24
+ # @return [String, nil] DER-encoded timestamp token
25
+ def sign(_io, _byte_range)
26
+ token = @timestamp_token.respond_to?(:call) ? @timestamp_token.call : @timestamp_token
27
+ token&.token_der
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ module Providers
5
+ # Abstract base class for hardware token providers
6
+ #
7
+ # Subclasses must implement:
8
+ # - #connect - Establish connection to the token
9
+ # - #disconnect - Close connection to the token
10
+ # - #login(pin) - Authenticate with PIN
11
+ # - #logout - End authenticated session
12
+ # - #sign(data, algorithm:) - Sign data with the private key
13
+ # - #certificate - Return the signing certificate
14
+ # - #certificate_chain - Return the full certificate chain
15
+ #
16
+ class Base
17
+ attr_reader :configuration
18
+
19
+ def initialize(configuration)
20
+ @configuration = configuration
21
+ @connected = false
22
+ @logged_in = false
23
+ end
24
+
25
+ # Connect to the hardware token
26
+ # @raise [TokenNotFoundError] if token is not connected
27
+ # @return [void]
28
+ def connect
29
+ raise NotImplementedError, "#{self.class}#connect must be implemented"
30
+ end
31
+
32
+ # Disconnect from the hardware token
33
+ # @return [void]
34
+ def disconnect
35
+ raise NotImplementedError, "#{self.class}#disconnect must be implemented"
36
+ end
37
+
38
+ # Authenticate with PIN
39
+ # @param pin [String] the PIN for the token
40
+ # @raise [PinError] if PIN is incorrect
41
+ # @raise [TokenLockedError] if token is locked
42
+ # @return [void]
43
+ def login(pin)
44
+ raise NotImplementedError, "#{self.class}#login must be implemented"
45
+ end
46
+
47
+ # End authenticated session
48
+ # @return [void]
49
+ def logout
50
+ raise NotImplementedError, "#{self.class}#logout must be implemented"
51
+ end
52
+
53
+ # Sign data using the private key on the token
54
+ # @param data [String] the data to sign (typically a hash)
55
+ # @param algorithm [Symbol] signature algorithm (:sha256_rsa, :sha384_rsa, :sha512_rsa, etc.)
56
+ # @raise [SignatureGenerationError] if signing fails
57
+ # @return [String] the raw signature bytes
58
+ def sign(data, algorithm:)
59
+ raise NotImplementedError, "#{self.class}#sign must be implemented"
60
+ end
61
+
62
+ # Get the signing certificate from the token
63
+ # @raise [KeyNotFoundError] if no certificate is found
64
+ # @return [OpenSSL::X509::Certificate]
65
+ def certificate
66
+ raise NotImplementedError, "#{self.class}#certificate must be implemented"
67
+ end
68
+
69
+ # Get the full certificate chain from the token
70
+ # @return [Array<OpenSSL::X509::Certificate>] certificates ordered from leaf to root
71
+ def certificate_chain
72
+ raise NotImplementedError, "#{self.class}#certificate_chain must be implemented"
73
+ end
74
+
75
+ # Check if connected to the token
76
+ # @return [Boolean]
77
+ def connected?
78
+ @connected
79
+ end
80
+
81
+ # Check if authenticated (logged in)
82
+ # @return [Boolean]
83
+ def logged_in?
84
+ @logged_in
85
+ end
86
+
87
+ # List available slots/tokens
88
+ # @return [Array<Hash>] array of slot information hashes
89
+ def list_slots
90
+ raise NotImplementedError, "#{self.class}#list_slots must be implemented"
91
+ end
92
+
93
+ # Execute a block with automatic connect/login/logout/disconnect
94
+ # @param pin [String, nil] PIN for authentication (uses callback if nil)
95
+ # @yield [provider] yields self to the block
96
+ # @return [Object] result of the block
97
+ def with_session(pin: nil)
98
+ pin ||= request_pin
99
+ connect
100
+ login(pin)
101
+ yield self
102
+ ensure
103
+ logout if logged_in?
104
+ disconnect if connected?
105
+ end
106
+
107
+ protected
108
+
109
+ def log(level, message)
110
+ return unless configuration.logger
111
+
112
+ configuration.logger.send(level, "[EasyCodeSign] #{message}")
113
+ end
114
+
115
+ private
116
+
117
+ def request_pin
118
+ callback = configuration.pin_callback
119
+ raise ConfigurationError, "No PIN provided and no pin_callback configured" unless callback
120
+
121
+ slot_info = { slot_index: configuration.slot_index }
122
+ callback.call(slot_info)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pkcs11"
4
+ require "openssl"
5
+
6
+ module EasyCodeSign
7
+ module Providers
8
+ # Base class for PKCS#11-based token providers
9
+ #
10
+ # Provides common functionality for tokens that use the PKCS#11 interface.
11
+ # Subclasses can override methods to handle token-specific behavior.
12
+ #
13
+ class Pkcs11Base < Base
14
+ # PKCS#11 mechanism mappings for signature algorithms
15
+ SIGNATURE_MECHANISMS = {
16
+ sha256_rsa: :CKM_SHA256_RSA_PKCS,
17
+ sha384_rsa: :CKM_SHA384_RSA_PKCS,
18
+ sha512_rsa: :CKM_SHA512_RSA_PKCS,
19
+ sha256_ecdsa: :CKM_ECDSA_SHA256,
20
+ sha384_ecdsa: :CKM_ECDSA_SHA384,
21
+ sha512_ecdsa: :CKM_ECDSA_SHA512
22
+ }.freeze
23
+
24
+ def initialize(configuration)
25
+ super
26
+ @pkcs11 = nil
27
+ @session = nil
28
+ @slot = nil
29
+ end
30
+
31
+ def connect
32
+ log :debug, "Loading PKCS#11 library: #{configuration.pkcs11_library}"
33
+ @pkcs11 = PKCS11.open(configuration.pkcs11_library)
34
+
35
+ slots = available_slots
36
+ raise TokenNotFoundError, "No tokens found in any slot" if slots.empty?
37
+
38
+ @slot = slots[configuration.slot_index]
39
+ raise TokenNotFoundError, "Slot #{configuration.slot_index} not found" unless @slot
40
+
41
+ log :debug, "Opening session on slot #{configuration.slot_index}"
42
+ @session = @slot.open(PKCS11::CKF_SERIAL_SESSION | PKCS11::CKF_RW_SESSION)
43
+ @connected = true
44
+ rescue PKCS11::Error => e
45
+ raise Pkcs11Error.new("Failed to connect: #{e.message}", pkcs11_error_code: e.error_code)
46
+ end
47
+
48
+ def disconnect
49
+ return unless @connected
50
+
51
+ log :debug, "Closing PKCS#11 session"
52
+ @session&.close
53
+ @pkcs11&.close
54
+ @session = nil
55
+ @pkcs11 = nil
56
+ @slot = nil
57
+ @connected = false
58
+ rescue PKCS11::Error => e
59
+ log :warn, "Error during disconnect: #{e.message}"
60
+ end
61
+
62
+ def login(pin)
63
+ raise TokenNotFoundError, "Not connected to token" unless @connected
64
+
65
+ log :debug, "Logging in to token"
66
+ @session.login(:USER, pin)
67
+ @logged_in = true
68
+ rescue PKCS11::Error => e
69
+ handle_login_error(e)
70
+ end
71
+
72
+ def logout
73
+ return unless @logged_in
74
+
75
+ log :debug, "Logging out from token"
76
+ @session&.logout
77
+ @logged_in = false
78
+ rescue PKCS11::Error => e
79
+ log :warn, "Error during logout: #{e.message}"
80
+ @logged_in = false
81
+ end
82
+
83
+ def sign(data, algorithm: :sha256_rsa)
84
+ raise TokenNotFoundError, "Not logged in" unless @logged_in
85
+
86
+ mechanism = SIGNATURE_MECHANISMS[algorithm]
87
+ raise SigningError, "Unsupported algorithm: #{algorithm}" unless mechanism
88
+
89
+ private_key = find_private_key
90
+ raise KeyNotFoundError, "No private key found on token" unless private_key
91
+
92
+ log :debug, "Signing #{data.bytesize} bytes with #{algorithm}"
93
+ @session.sign(mechanism, private_key, data)
94
+ rescue PKCS11::Error => e
95
+ raise SignatureGenerationError, "Signing failed: #{e.message}"
96
+ end
97
+
98
+ def certificate
99
+ @certificate ||= find_certificate
100
+ end
101
+
102
+ def certificate_chain
103
+ @certificate_chain ||= build_certificate_chain
104
+ end
105
+
106
+ def list_slots
107
+ @pkcs11 ||= PKCS11.open(configuration.pkcs11_library)
108
+ @pkcs11.slots.map.with_index do |slot, index|
109
+ token_info = begin
110
+ slot.token_info
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ {
116
+ index: index,
117
+ description: slot.info.slot_description.strip,
118
+ token_present: token_info != nil,
119
+ token_label: token_info&.label&.strip,
120
+ manufacturer: token_info&.manufacturer_id&.strip,
121
+ serial: token_info&.serial_number&.strip
122
+ }
123
+ end
124
+ end
125
+
126
+ protected
127
+
128
+ def available_slots
129
+ @pkcs11.slots.select do |slot|
130
+ slot.token_info rescue false # Returns false if no token present
131
+ end
132
+ end
133
+
134
+ def find_private_key
135
+ @session.find_objects(CKA_CLASS: PKCS11::CKO_PRIVATE_KEY).first
136
+ end
137
+
138
+ def find_certificate
139
+ cert_obj = @session.find_objects(CKA_CLASS: PKCS11::CKO_CERTIFICATE).first
140
+ raise KeyNotFoundError, "No certificate found on token" unless cert_obj
141
+
142
+ cert_der = @session.get_attribute_value(cert_obj, :CKA_VALUE).first
143
+ OpenSSL::X509::Certificate.new(cert_der)
144
+ end
145
+
146
+ def build_certificate_chain
147
+ certs = @session.find_objects(CKA_CLASS: PKCS11::CKO_CERTIFICATE).map do |cert_obj|
148
+ cert_der = @session.get_attribute_value(cert_obj, :CKA_VALUE).first
149
+ OpenSSL::X509::Certificate.new(cert_der)
150
+ end
151
+
152
+ # Sort chain: leaf first, then intermediates, then root
153
+ sort_certificate_chain(certs)
154
+ end
155
+
156
+ def sort_certificate_chain(certs)
157
+ return certs if certs.size <= 1
158
+
159
+ # Find the leaf certificate (not an issuer of any other cert)
160
+ issuers = certs.map(&:subject).map(&:to_s)
161
+ leaf = certs.find { |c| !issuers.include?(c.issuer.to_s) || c.subject == c.issuer }
162
+
163
+ return certs unless leaf
164
+
165
+ # Build chain from leaf
166
+ chain = [leaf]
167
+ remaining = certs - [leaf]
168
+
169
+ while remaining.any?
170
+ current = chain.last
171
+ issuer = remaining.find { |c| c.subject.to_s == current.issuer.to_s }
172
+ break unless issuer
173
+
174
+ chain << issuer
175
+ remaining.delete(issuer)
176
+ end
177
+
178
+ chain
179
+ end
180
+
181
+ private
182
+
183
+ def handle_login_error(error)
184
+ case error.error_code
185
+ when PKCS11::CKR_PIN_INCORRECT
186
+ raise PinError, "Incorrect PIN"
187
+ when PKCS11::CKR_PIN_LOCKED
188
+ raise TokenLockedError, "Token is locked due to too many failed PIN attempts"
189
+ when PKCS11::CKR_PIN_EXPIRED
190
+ raise PinError, "PIN has expired and must be changed"
191
+ else
192
+ raise Pkcs11Error.new("Login failed: #{error.message}", pkcs11_error_code: error.error_code)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCodeSign
4
+ module Providers
5
+ # SafeNet eToken provider
6
+ #
7
+ # Implements PKCS#11 integration for SafeNet eToken hardware security tokens.
8
+ # These tokens are commonly used for code signing certificates.
9
+ #
10
+ # @example
11
+ # provider = EasyCodeSign::Providers::Safenet.new(config)
12
+ # provider.with_session(pin: "1234") do |p|
13
+ # signature = p.sign(digest, algorithm: :sha256_rsa)
14
+ # end
15
+ #
16
+ class Safenet < Pkcs11Base
17
+ # Default PKCS#11 library paths for SafeNet tokens
18
+ DEFAULT_LIBRARY_PATHS = {
19
+ darwin: [
20
+ "/usr/local/lib/libeToken.dylib",
21
+ "/Library/Frameworks/eToken.framework/Versions/Current/libeToken.dylib"
22
+ ],
23
+ linux: [
24
+ "/usr/lib/libeToken.so",
25
+ "/usr/lib/x86_64-linux-gnu/libeToken.so",
26
+ "/opt/safenet/eToken/lib/libeToken.so"
27
+ ],
28
+ windows: [
29
+ "C:\\Windows\\System32\\eToken.dll",
30
+ "C:\\Program Files\\SafeNet\\Authentication\\eToken PKI Client\\x64\\eToken.dll"
31
+ ]
32
+ }.freeze
33
+
34
+ def initialize(configuration)
35
+ super
36
+ auto_detect_library! if configuration.pkcs11_library.nil?
37
+ end
38
+
39
+ # SafeNet tokens may require specific initialization
40
+ def connect
41
+ log :info, "Connecting to SafeNet eToken"
42
+ super
43
+ log :info, "Connected to SafeNet eToken: #{token_label}"
44
+ end
45
+
46
+ # Get the token label/name
47
+ # @return [String]
48
+ def token_label
49
+ return nil unless @slot
50
+
51
+ @slot.token_info.label.strip
52
+ rescue PKCS11::Error
53
+ nil
54
+ end
55
+
56
+ # Get token serial number
57
+ # @return [String]
58
+ def serial_number
59
+ return nil unless @slot
60
+
61
+ @slot.token_info.serial_number.strip
62
+ rescue PKCS11::Error
63
+ nil
64
+ end
65
+
66
+ # Check if token requires PIN change
67
+ # @return [Boolean]
68
+ def pin_change_required?
69
+ return false unless @slot
70
+
71
+ flags = @slot.token_info.flags
72
+ (flags & PKCS11::CKF_USER_PIN_TO_BE_CHANGED) != 0
73
+ rescue PKCS11::Error
74
+ false
75
+ end
76
+
77
+ private
78
+
79
+ def auto_detect_library!
80
+ platform = detect_platform
81
+ paths = DEFAULT_LIBRARY_PATHS[platform] || []
82
+
83
+ found = paths.find { |path| File.exist?(path) }
84
+
85
+ if found
86
+ log :debug, "Auto-detected SafeNet library: #{found}"
87
+ configuration.pkcs11_library = found
88
+ else
89
+ raise ConfigurationError,
90
+ "SafeNet PKCS#11 library not found. Searched: #{paths.join(', ')}. " \
91
+ "Please set pkcs11_library explicitly."
92
+ end
93
+ end
94
+
95
+ def detect_platform
96
+ case RUBY_PLATFORM
97
+ when /darwin/
98
+ :darwin
99
+ when /linux/
100
+ :linux
101
+ when /mswin|mingw/
102
+ :windows
103
+ else
104
+ :linux # Default fallback
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end