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