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,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyCodeSign
|
|
4
|
+
# Orchestrates the signing process for different file types
|
|
5
|
+
#
|
|
6
|
+
# @example Sign a gem file
|
|
7
|
+
# signer = EasyCodeSign::Signer.new
|
|
8
|
+
# result = signer.sign("my_gem-1.0.0.gem", pin: "1234")
|
|
9
|
+
#
|
|
10
|
+
# @example Sign with timestamp
|
|
11
|
+
# signer = EasyCodeSign::Signer.new
|
|
12
|
+
# result = signer.sign("archive.zip", pin: "1234", timestamp: true)
|
|
13
|
+
#
|
|
14
|
+
class Signer
|
|
15
|
+
attr_reader :provider, :configuration
|
|
16
|
+
|
|
17
|
+
def initialize(provider: nil, configuration: nil)
|
|
18
|
+
@configuration = configuration || EasyCodeSign.configuration
|
|
19
|
+
@provider = provider || EasyCodeSign.provider
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sign a file
|
|
23
|
+
#
|
|
24
|
+
# @param file_path [String] path to file to sign
|
|
25
|
+
# @param pin [String, nil] PIN for hardware token (uses callback if nil)
|
|
26
|
+
# @param output_path [String, nil] output path (defaults to overwriting input)
|
|
27
|
+
# @param timestamp [Boolean] whether to add RFC 3161 timestamp
|
|
28
|
+
# @param algorithm [Symbol] signature algorithm (:sha256_rsa, etc.)
|
|
29
|
+
# @return [SigningResult] result containing signed file path and metadata
|
|
30
|
+
#
|
|
31
|
+
def sign(file_path, pin: nil, output_path: nil, timestamp: nil, algorithm: :sha256_rsa, **extra_options)
|
|
32
|
+
timestamp = configuration.require_timestamp if timestamp.nil?
|
|
33
|
+
|
|
34
|
+
signable = create_signable(file_path, output_path: output_path, algorithm: algorithm, **extra_options)
|
|
35
|
+
signable.prepare_for_signing
|
|
36
|
+
|
|
37
|
+
provider.with_session(pin: pin) do |session|
|
|
38
|
+
certificate_chain = session.certificate_chain
|
|
39
|
+
timestamp_token = nil
|
|
40
|
+
|
|
41
|
+
# PDF files use deferred signing (callback-based)
|
|
42
|
+
if signable.is_a?(Signable::PdfFile)
|
|
43
|
+
# Create signing callback for PDF
|
|
44
|
+
signing_callback = lambda do |data_to_sign|
|
|
45
|
+
sig = session.sign(data_to_sign, algorithm: algorithm)
|
|
46
|
+
# Request timestamp on the signature if needed
|
|
47
|
+
if timestamp
|
|
48
|
+
timestamp_token = request_timestamp(sig)
|
|
49
|
+
end
|
|
50
|
+
sig
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
signed_path = signable.apply_signature(
|
|
54
|
+
signing_callback,
|
|
55
|
+
certificate_chain,
|
|
56
|
+
timestamp_token: -> { timestamp_token } # Lazy accessor since it's set in callback
|
|
57
|
+
)
|
|
58
|
+
else
|
|
59
|
+
# Standard flow for gem/zip files
|
|
60
|
+
content = signable.content_to_sign
|
|
61
|
+
signature = session.sign(content, algorithm: algorithm)
|
|
62
|
+
|
|
63
|
+
if timestamp
|
|
64
|
+
timestamp_token = request_timestamp(signature)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
signed_path = signable.apply_signature(signature, certificate_chain, timestamp_token: timestamp_token)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
SigningResult.new(
|
|
71
|
+
file_path: signed_path,
|
|
72
|
+
certificate: certificate_chain.first,
|
|
73
|
+
algorithm: algorithm,
|
|
74
|
+
timestamp_token: timestamp_token,
|
|
75
|
+
signed_at: Time.now
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Phase 1 of deferred PDF signing: prepare a PDF with a placeholder signature
|
|
81
|
+
# and return a DeferredSigningRequest containing the digest to be signed externally.
|
|
82
|
+
#
|
|
83
|
+
# Requires a hardware token session (PIN) to retrieve the signing certificate.
|
|
84
|
+
#
|
|
85
|
+
# @param file_path [String] path to the PDF
|
|
86
|
+
# @param pin [String, nil] PIN for hardware token
|
|
87
|
+
# @param digest_algorithm [String] hash algorithm ("sha256", "sha384", "sha512")
|
|
88
|
+
# @param timestamp [Boolean] whether to reserve space for a timestamp
|
|
89
|
+
# @return [DeferredSigningRequest]
|
|
90
|
+
def prepare_pdf(file_path, pin: nil, digest_algorithm: "sha256", timestamp: false, **extra_options)
|
|
91
|
+
signable = Signable::PdfFile.new(file_path, **extra_options)
|
|
92
|
+
timestamp_size = timestamp ? 4096 : 0
|
|
93
|
+
|
|
94
|
+
provider.with_session(pin: pin) do |session|
|
|
95
|
+
certificate_chain = session.certificate_chain
|
|
96
|
+
|
|
97
|
+
signable.prepare_deferred(
|
|
98
|
+
certificate_chain.first,
|
|
99
|
+
certificate_chain,
|
|
100
|
+
digest_algorithm: digest_algorithm,
|
|
101
|
+
timestamp_size: timestamp_size
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Phase 2 of deferred PDF signing: embed an externally-produced signature
|
|
107
|
+
# into the prepared PDF. No hardware token needed.
|
|
108
|
+
#
|
|
109
|
+
# @param deferred_request [DeferredSigningRequest] from Phase 1
|
|
110
|
+
# @param raw_signature [String] raw signature bytes from the external signer
|
|
111
|
+
# @return [SigningResult]
|
|
112
|
+
def finalize_pdf(deferred_request, raw_signature, timestamp: nil, timestamp_token: nil)
|
|
113
|
+
timestamp = configuration.require_timestamp if timestamp.nil?
|
|
114
|
+
|
|
115
|
+
if timestamp && timestamp_token.nil?
|
|
116
|
+
timestamp_token = request_timestamp(raw_signature)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
signable = Signable::PdfFile.new(deferred_request.prepared_pdf_path)
|
|
120
|
+
|
|
121
|
+
signed_path = signable.finalize_deferred(deferred_request, raw_signature, timestamp_token: timestamp_token)
|
|
122
|
+
|
|
123
|
+
SigningResult.new(
|
|
124
|
+
file_path: signed_path,
|
|
125
|
+
certificate: deferred_request.certificate,
|
|
126
|
+
algorithm: :"#{deferred_request.digest_algorithm}_rsa",
|
|
127
|
+
timestamp_token: timestamp_token,
|
|
128
|
+
signed_at: deferred_request.signing_time
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Sign multiple files
|
|
133
|
+
#
|
|
134
|
+
# @param file_paths [Array<String>] paths to files to sign
|
|
135
|
+
# @param pin [String, nil] PIN for hardware token
|
|
136
|
+
# @param options [Hash] signing options passed to #sign
|
|
137
|
+
# @return [Array<SigningResult>] results for each file
|
|
138
|
+
#
|
|
139
|
+
def sign_batch(file_paths, pin: nil, **options)
|
|
140
|
+
results = []
|
|
141
|
+
|
|
142
|
+
provider.with_session(pin: pin) do |session|
|
|
143
|
+
file_paths.each do |path|
|
|
144
|
+
# Re-use the open session for batch signing
|
|
145
|
+
result = sign_with_session(session, path, **options)
|
|
146
|
+
results << result
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
results
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def create_signable(file_path, **options)
|
|
156
|
+
extension = File.extname(file_path).downcase
|
|
157
|
+
|
|
158
|
+
case extension
|
|
159
|
+
when ".gem"
|
|
160
|
+
Signable::GemFile.new(file_path, **options)
|
|
161
|
+
when ".zip", ".jar", ".apk", ".war", ".ear"
|
|
162
|
+
Signable::ZipFile.new(file_path, **options)
|
|
163
|
+
when ".pdf"
|
|
164
|
+
Signable::PdfFile.new(file_path, **options)
|
|
165
|
+
else
|
|
166
|
+
raise InvalidFileError, "Unsupported file type: #{extension}. " \
|
|
167
|
+
"Supported: .gem, .zip, .jar, .apk, .war, .ear, .pdf"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def sign_with_session(session, file_path, output_path: nil, timestamp: nil, algorithm: :sha256_rsa)
|
|
172
|
+
timestamp = configuration.require_timestamp if timestamp.nil?
|
|
173
|
+
|
|
174
|
+
signable = create_signable(file_path, output_path: output_path, algorithm: algorithm)
|
|
175
|
+
signable.prepare_for_signing
|
|
176
|
+
|
|
177
|
+
content = signable.content_to_sign
|
|
178
|
+
signature = session.sign(content, algorithm: algorithm)
|
|
179
|
+
certificate_chain = session.certificate_chain
|
|
180
|
+
|
|
181
|
+
timestamp_token = timestamp ? request_timestamp(signature) : nil
|
|
182
|
+
|
|
183
|
+
signed_path = signable.apply_signature(signature, certificate_chain, timestamp_token: timestamp_token)
|
|
184
|
+
|
|
185
|
+
SigningResult.new(
|
|
186
|
+
file_path: signed_path,
|
|
187
|
+
certificate: certificate_chain.first,
|
|
188
|
+
algorithm: algorithm,
|
|
189
|
+
timestamp_token: timestamp_token,
|
|
190
|
+
signed_at: Time.now
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def request_timestamp(signature)
|
|
195
|
+
return nil unless configuration.timestamp_authority
|
|
196
|
+
|
|
197
|
+
client = Timestamp::Client.new(
|
|
198
|
+
configuration.timestamp_authority,
|
|
199
|
+
timeout: configuration.network_timeout
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
client.timestamp(
|
|
203
|
+
signature,
|
|
204
|
+
algorithm: configuration.timestamp_hash_algorithm
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Result of a signing operation
|
|
210
|
+
class SigningResult
|
|
211
|
+
attr_reader :file_path, :certificate, :algorithm, :timestamp_token, :signed_at
|
|
212
|
+
|
|
213
|
+
def initialize(file_path:, certificate:, algorithm:, timestamp_token:, signed_at:)
|
|
214
|
+
@file_path = file_path
|
|
215
|
+
@certificate = certificate
|
|
216
|
+
@algorithm = algorithm
|
|
217
|
+
@timestamp_token = timestamp_token
|
|
218
|
+
@signed_at = signed_at
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def timestamped?
|
|
222
|
+
!timestamp_token.nil?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Get the timestamp time
|
|
226
|
+
# @return [Time, nil]
|
|
227
|
+
def timestamp
|
|
228
|
+
timestamp_token&.timestamp
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def signer_name
|
|
232
|
+
certificate.subject.to_s
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Get TSA info if timestamped
|
|
236
|
+
# @return [Hash, nil]
|
|
237
|
+
def timestamp_info
|
|
238
|
+
return nil unless timestamp_token
|
|
239
|
+
|
|
240
|
+
timestamp_token.to_h
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def to_h
|
|
244
|
+
{
|
|
245
|
+
file_path: file_path,
|
|
246
|
+
signer: signer_name,
|
|
247
|
+
algorithm: algorithm,
|
|
248
|
+
timestamped: timestamped?,
|
|
249
|
+
timestamp: timestamp_info,
|
|
250
|
+
signed_at: signed_at
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module EasyCodeSign
|
|
7
|
+
module Timestamp
|
|
8
|
+
# RFC 3161 Timestamp Authority Client
|
|
9
|
+
#
|
|
10
|
+
# Communicates with a TSA to obtain timestamp tokens for signatures.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# client = Timestamp::Client.new("http://timestamp.digicert.com")
|
|
14
|
+
# token = client.timestamp(signature_bytes, algorithm: :sha256)
|
|
15
|
+
#
|
|
16
|
+
class Client
|
|
17
|
+
CONTENT_TYPE = "application/timestamp-query"
|
|
18
|
+
ACCEPT_TYPE = "application/timestamp-reply"
|
|
19
|
+
|
|
20
|
+
attr_reader :url, :timeout, :username, :password
|
|
21
|
+
|
|
22
|
+
# Common TSA URLs for reference
|
|
23
|
+
KNOWN_TSAS = {
|
|
24
|
+
digicert: "http://timestamp.digicert.com",
|
|
25
|
+
globalsign: "http://timestamp.globalsign.com/tsa/r6advanced1",
|
|
26
|
+
sectigo: "http://timestamp.sectigo.com",
|
|
27
|
+
comodo: "http://timestamp.comodoca.com",
|
|
28
|
+
entrust: "http://timestamp.entrust.net/TSS/RFC3161sha2TS",
|
|
29
|
+
ssl_com: "http://ts.ssl.com"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Create a new TSA client
|
|
33
|
+
#
|
|
34
|
+
# @param url [String] TSA URL
|
|
35
|
+
# @param timeout [Integer] HTTP timeout in seconds
|
|
36
|
+
# @param username [String, nil] HTTP Basic auth username
|
|
37
|
+
# @param password [String, nil] HTTP Basic auth password
|
|
38
|
+
#
|
|
39
|
+
def initialize(url, timeout: 30, username: nil, password: nil)
|
|
40
|
+
@url = url
|
|
41
|
+
@timeout = timeout
|
|
42
|
+
@username = username
|
|
43
|
+
@password = password
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Request a timestamp for data
|
|
47
|
+
#
|
|
48
|
+
# @param data [String] data to timestamp (typically a signature)
|
|
49
|
+
# @param algorithm [Symbol] hash algorithm (:sha256, :sha384, :sha512)
|
|
50
|
+
# @param cert_req [Boolean] request TSA certificate in response
|
|
51
|
+
# @return [TimestampToken] the timestamp token
|
|
52
|
+
# @raise [TimestampAuthorityError] if TSA request fails
|
|
53
|
+
# @raise [InvalidTimestampError] if response is invalid
|
|
54
|
+
#
|
|
55
|
+
def timestamp(data, algorithm: :sha256, cert_req: true)
|
|
56
|
+
request = Request.new(data, algorithm: algorithm, cert_req: cert_req)
|
|
57
|
+
response = send_request(request)
|
|
58
|
+
|
|
59
|
+
validate_response!(response, request)
|
|
60
|
+
|
|
61
|
+
TimestampToken.new(
|
|
62
|
+
token_der: response.token_der,
|
|
63
|
+
timestamp: response.timestamp,
|
|
64
|
+
serial_number: response.serial_number,
|
|
65
|
+
policy_oid: response.policy_oid,
|
|
66
|
+
tsa_url: url
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if the TSA is reachable
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def available?
|
|
73
|
+
uri = URI.parse(url)
|
|
74
|
+
http = build_http(uri)
|
|
75
|
+
http.open_timeout = 5
|
|
76
|
+
http.read_timeout = 5
|
|
77
|
+
|
|
78
|
+
response = http.head(uri.path.empty? ? "/" : uri.path)
|
|
79
|
+
response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPMethodNotAllowed)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def send_request(request)
|
|
87
|
+
uri = URI.parse(url)
|
|
88
|
+
http = build_http(uri)
|
|
89
|
+
|
|
90
|
+
http_request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
|
|
91
|
+
http_request["Content-Type"] = CONTENT_TYPE
|
|
92
|
+
http_request["Accept"] = ACCEPT_TYPE
|
|
93
|
+
http_request.body = request.to_der
|
|
94
|
+
|
|
95
|
+
if username && password
|
|
96
|
+
http_request.basic_auth(username, password)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
response = http.request(http_request)
|
|
100
|
+
|
|
101
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
102
|
+
raise TimestampAuthorityError.new(
|
|
103
|
+
"TSA returned HTTP #{response.code}: #{response.message}",
|
|
104
|
+
http_status: response.code.to_i
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Response.parse(response.body)
|
|
109
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
|
|
110
|
+
raise TimestampAuthorityError, "Failed to connect to TSA: #{e.message}"
|
|
111
|
+
rescue SocketError => e
|
|
112
|
+
raise TimestampAuthorityError, "DNS resolution failed for TSA: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_http(uri)
|
|
116
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
117
|
+
http.use_ssl = (uri.scheme == "https")
|
|
118
|
+
http.open_timeout = timeout
|
|
119
|
+
http.read_timeout = timeout
|
|
120
|
+
|
|
121
|
+
if http.use_ssl?
|
|
122
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
http
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_response!(response, request)
|
|
129
|
+
unless response.success?
|
|
130
|
+
raise TimestampAuthorityError, response.error_message
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
unless response.nonce_matches?(request.nonce)
|
|
134
|
+
raise InvalidTimestampError, "Nonce mismatch: response nonce does not match request"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Verify message imprint matches
|
|
138
|
+
if response.message_imprint_hash != request.message_imprint_hash
|
|
139
|
+
raise InvalidTimestampError, "Message imprint mismatch: TSA timestamped different data"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Represents a timestamp token obtained from a TSA
|
|
145
|
+
class TimestampToken
|
|
146
|
+
attr_reader :token_der, :timestamp, :serial_number, :policy_oid, :tsa_url
|
|
147
|
+
|
|
148
|
+
def initialize(token_der:, timestamp:, serial_number:, policy_oid:, tsa_url:)
|
|
149
|
+
@token_der = token_der
|
|
150
|
+
@timestamp = timestamp
|
|
151
|
+
@serial_number = serial_number
|
|
152
|
+
@policy_oid = policy_oid
|
|
153
|
+
@tsa_url = tsa_url
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Get the timestamp as ISO 8601 string
|
|
157
|
+
# @return [String]
|
|
158
|
+
def timestamp_iso8601
|
|
159
|
+
timestamp&.iso8601
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get the PKCS#7 structure
|
|
163
|
+
# @return [OpenSSL::PKCS7]
|
|
164
|
+
def pkcs7
|
|
165
|
+
@pkcs7 ||= OpenSSL::PKCS7.new(token_der)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Get the TSA signing certificate (if included in response)
|
|
169
|
+
# @return [OpenSSL::X509::Certificate, nil]
|
|
170
|
+
def tsa_certificate
|
|
171
|
+
pkcs7.certificates&.first
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_h
|
|
175
|
+
{
|
|
176
|
+
timestamp: timestamp_iso8601,
|
|
177
|
+
serial_number: serial_number,
|
|
178
|
+
policy_oid: policy_oid,
|
|
179
|
+
tsa_url: tsa_url
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module EasyCodeSign
|
|
7
|
+
module Timestamp
|
|
8
|
+
# RFC 3161 Timestamp Request builder
|
|
9
|
+
#
|
|
10
|
+
# Creates a TimeStampReq ASN.1 structure for submission to a TSA.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# request = Timestamp::Request.new(signature_data, algorithm: :sha256)
|
|
14
|
+
# der_bytes = request.to_der
|
|
15
|
+
#
|
|
16
|
+
class Request
|
|
17
|
+
# OIDs for hash algorithms
|
|
18
|
+
HASH_ALGORITHM_OIDS = {
|
|
19
|
+
sha256: "2.16.840.1.101.3.4.2.1",
|
|
20
|
+
sha384: "2.16.840.1.101.3.4.2.2",
|
|
21
|
+
sha512: "2.16.840.1.101.3.4.2.3",
|
|
22
|
+
sha1: "1.3.14.3.2.26" # Legacy, not recommended
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :data, :algorithm, :nonce, :cert_req, :policy_oid
|
|
26
|
+
|
|
27
|
+
# Create a new timestamp request
|
|
28
|
+
#
|
|
29
|
+
# @param data [String] the data to timestamp (typically a signature)
|
|
30
|
+
# @param algorithm [Symbol] hash algorithm (:sha256, :sha384, :sha512)
|
|
31
|
+
# @param cert_req [Boolean] request signing certificate in response
|
|
32
|
+
# @param policy_oid [String, nil] optional TSA policy OID
|
|
33
|
+
#
|
|
34
|
+
def initialize(data, algorithm: :sha256, cert_req: true, policy_oid: nil)
|
|
35
|
+
@data = data
|
|
36
|
+
@algorithm = algorithm
|
|
37
|
+
@cert_req = cert_req
|
|
38
|
+
@policy_oid = policy_oid
|
|
39
|
+
@nonce = generate_nonce
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get the message digest of the data
|
|
43
|
+
# @return [String] hash bytes
|
|
44
|
+
def message_imprint_hash
|
|
45
|
+
digest_class.digest(data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Encode the request as DER
|
|
49
|
+
# @return [String] DER-encoded TimeStampReq
|
|
50
|
+
def to_der
|
|
51
|
+
# TimeStampReq ::= SEQUENCE {
|
|
52
|
+
# version INTEGER { v1(1) },
|
|
53
|
+
# messageImprint MessageImprint,
|
|
54
|
+
# reqPolicy TSAPolicyId OPTIONAL,
|
|
55
|
+
# nonce INTEGER OPTIONAL,
|
|
56
|
+
# certReq BOOLEAN DEFAULT FALSE,
|
|
57
|
+
# extensions [0] IMPLICIT Extensions OPTIONAL
|
|
58
|
+
# }
|
|
59
|
+
|
|
60
|
+
seq = OpenSSL::ASN1::Sequence.new([
|
|
61
|
+
OpenSSL::ASN1::Integer.new(1), # version
|
|
62
|
+
message_imprint_asn1,
|
|
63
|
+
# reqPolicy omitted if nil
|
|
64
|
+
policy_oid ? OpenSSL::ASN1::ObjectId.new(policy_oid) : nil,
|
|
65
|
+
OpenSSL::ASN1::Integer.new(nonce),
|
|
66
|
+
OpenSSL::ASN1::Boolean.new(cert_req)
|
|
67
|
+
].compact)
|
|
68
|
+
|
|
69
|
+
seq.to_der
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get the hash algorithm OID
|
|
73
|
+
# @return [String]
|
|
74
|
+
def algorithm_oid
|
|
75
|
+
HASH_ALGORITHM_OIDS[algorithm] or
|
|
76
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def message_imprint_asn1
|
|
82
|
+
# MessageImprint ::= SEQUENCE {
|
|
83
|
+
# hashAlgorithm AlgorithmIdentifier,
|
|
84
|
+
# hashedMessage OCTET STRING
|
|
85
|
+
# }
|
|
86
|
+
|
|
87
|
+
algo_id = OpenSSL::ASN1::Sequence.new([
|
|
88
|
+
OpenSSL::ASN1::ObjectId.new(algorithm_oid),
|
|
89
|
+
OpenSSL::ASN1::Null.new(nil)
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
OpenSSL::ASN1::Sequence.new([
|
|
93
|
+
algo_id,
|
|
94
|
+
OpenSSL::ASN1::OctetString.new(message_imprint_hash)
|
|
95
|
+
])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def digest_class
|
|
99
|
+
case algorithm
|
|
100
|
+
when :sha256 then OpenSSL::Digest::SHA256
|
|
101
|
+
when :sha384 then OpenSSL::Digest::SHA384
|
|
102
|
+
when :sha512 then OpenSSL::Digest::SHA512
|
|
103
|
+
when :sha1 then OpenSSL::Digest::SHA1
|
|
104
|
+
else raise ArgumentError, "Unsupported algorithm: #{algorithm}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def generate_nonce
|
|
109
|
+
# Generate a random 64-bit nonce
|
|
110
|
+
SecureRandom.random_number(2**64)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|