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
+ require "minitest/autorun"
4
+ require "json"
5
+
6
+ # Add paths for testing
7
+ $LOAD_PATH.unshift(File.expand_path("../../src", __dir__))
8
+ $LOAD_PATH.unshift(File.expand_path("../../../lib", __dir__))
9
+
10
+ require "protocol"
11
+
12
+ class ProtocolTest < Minitest::Test
13
+ def test_sign_response_creates_correct_structure
14
+ result = {
15
+ signed_pdf_data: "base64data",
16
+ signer_name: "CN=Test User",
17
+ signed_at: Time.now,
18
+ timestamped: true
19
+ }
20
+
21
+ response = EasySign::Protocol.sign_response("req-123", result)
22
+
23
+ assert_equal "sign_response", response[:type]
24
+ assert_equal "req-123", response[:requestId]
25
+ assert_nil response[:error]
26
+ assert_equal "base64data", response[:payload][:signedPdfData]
27
+ assert_equal "CN=Test User", response[:payload][:signerName]
28
+ assert response[:payload][:timestamped]
29
+ end
30
+
31
+ def test_error_response_creates_correct_structure
32
+ response = EasySign::Protocol.error_response(
33
+ "req-456",
34
+ EasySign::Protocol::ErrorCodes::PIN_INCORRECT,
35
+ "Wrong PIN",
36
+ { retriesRemaining: 2 }
37
+ )
38
+
39
+ assert_equal "error", response[:type]
40
+ assert_equal "req-456", response[:requestId]
41
+ assert_nil response[:payload]
42
+ assert_equal "PIN_INCORRECT", response[:error][:code]
43
+ assert_equal "Wrong PIN", response[:error][:message]
44
+ assert_equal 2, response[:error][:details][:retriesRemaining]
45
+ end
46
+
47
+ def test_verify_response_creates_correct_structure
48
+ result = {
49
+ valid: true,
50
+ signer_name: "CN=Test User",
51
+ signature_valid: true,
52
+ integrity_valid: true,
53
+ certificate_valid: true,
54
+ chain_valid: true,
55
+ trusted: true,
56
+ timestamped: false,
57
+ errors: [],
58
+ warnings: []
59
+ }
60
+
61
+ response = EasySign::Protocol.verify_response("req-789", result)
62
+
63
+ assert_equal "verify_response", response[:type]
64
+ assert_equal "req-789", response[:requestId]
65
+ assert response[:payload][:valid]
66
+ assert response[:payload][:signatureValid]
67
+ assert_empty response[:payload][:errors]
68
+ end
69
+
70
+ def test_availability_response_creates_correct_structure
71
+ result = {
72
+ available: true,
73
+ token_present: true,
74
+ slots: [
75
+ { index: 0, token_label: "Token1", manufacturer: "SafeNet", serial: "12345" }
76
+ ]
77
+ }
78
+
79
+ response = EasySign::Protocol.availability_response("req-000", result)
80
+
81
+ assert_equal "availability_response", response[:type]
82
+ assert response[:payload][:available]
83
+ assert response[:payload][:tokenPresent]
84
+ assert_equal 1, response[:payload][:slots].length
85
+ assert_equal "Token1", response[:payload][:slots][0][:tokenLabel]
86
+ end
87
+ end
88
+
89
+ class MessageFormatTest < Minitest::Test
90
+ def test_native_message_encoding
91
+ message = { type: "test", requestId: "123" }
92
+ json = message.to_json
93
+ length = [json.bytesize].pack("V")
94
+
95
+ # Verify length prefix format
96
+ assert_equal 4, length.bytesize
97
+ assert_equal json.bytesize, length.unpack1("V")
98
+ end
99
+
100
+ def test_native_message_decoding
101
+ message = { type: "sign", requestId: "abc", payload: { pdfData: "test" } }
102
+ json = message.to_json
103
+ length_bytes = [json.bytesize].pack("V")
104
+
105
+ # Simulate reading
106
+ decoded_length = length_bytes.unpack1("V")
107
+ assert_equal json.bytesize, decoded_length
108
+
109
+ decoded = JSON.parse(json, symbolize_names: true)
110
+ assert_equal "sign", decoded[:type]
111
+ assert_equal "abc", decoded[:requestId]
112
+ end
113
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+
4
+ require "native"
5
+ require "easy_sign/messaging"
6
+
7
+ # Background service worker for EasySign browser extension
8
+ # Handles communication between content scripts and native messaging host
9
+ module EasySign
10
+ class Background
11
+ NATIVE_HOST_NAME = "com.easysign.host"
12
+
13
+ def initialize
14
+ @native_port = nil
15
+ @pending_requests = {}
16
+ @current_signing_request = nil
17
+ setup_listeners
18
+ puts "EasySign background service worker initialized"
19
+ end
20
+
21
+ def setup_listeners
22
+ # Listen for messages from content scripts
23
+ `chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
24
+ #{handle_message(`message`, `sender`, `sendResponse`)}
25
+ return true; // Keep channel open for async response
26
+ })`
27
+
28
+ # Listen for connections (for popup communication)
29
+ `chrome.runtime.onConnect.addListener((port) => {
30
+ #{handle_port_connect(`port`)}
31
+ })`
32
+ end
33
+
34
+ def handle_message(message, sender, send_response)
35
+ type = message[:type] || `message.type`
36
+ request_id = message[:request_id] || `message.requestId` || `message.request_id`
37
+
38
+ case type
39
+ when Messaging::Types::SIGN_REQUEST
40
+ handle_sign_request(message, sender, send_response)
41
+ when Messaging::Types::VERIFY_REQUEST
42
+ handle_verify_request(message, sender, send_response)
43
+ when Messaging::Types::AVAILABILITY_REQUEST
44
+ handle_availability_request(send_response)
45
+ when Messaging::Types::CANCEL_REQUEST
46
+ handle_cancel_request(request_id, send_response)
47
+ else
48
+ send_error(send_response, request_id, Messaging::ErrorCodes::INTERNAL_ERROR,
49
+ "Unknown message type: #{type}")
50
+ end
51
+ end
52
+
53
+ def handle_port_connect(port)
54
+ port_name = `port.name`
55
+
56
+ case port_name
57
+ when "popup"
58
+ setup_popup_port(port)
59
+ end
60
+ end
61
+
62
+ def setup_popup_port(port)
63
+ @popup_port = port
64
+
65
+ `port.onMessage.addListener((message) => {
66
+ #{handle_popup_message(`message`)}
67
+ })`
68
+
69
+ `port.onDisconnect.addListener(() => {
70
+ #{handle_popup_disconnect}
71
+ })`
72
+ end
73
+
74
+ def handle_popup_message(message)
75
+ type = message[:type] || `message.type`
76
+
77
+ case type
78
+ when Messaging::Types::PIN_SUBMITTED
79
+ pin = message[:pin] || `message.pin`
80
+ complete_signing_with_pin(pin)
81
+ when Messaging::Types::PIN_CANCELLED
82
+ cancel_current_signing
83
+ when Messaging::Types::POPUP_READY
84
+ send_signing_context_to_popup
85
+ end
86
+ end
87
+
88
+ def handle_popup_disconnect
89
+ @popup_port = nil
90
+ # If popup closes without submitting PIN, cancel the operation
91
+ cancel_current_signing if @current_signing_request
92
+ end
93
+
94
+ def handle_sign_request(message, sender, send_response)
95
+ payload = message[:payload] || `message.payload`
96
+
97
+ # Validate origin
98
+ origin = `sender.origin || sender.url`
99
+ unless origin_allowed?(origin)
100
+ send_error(send_response, message[:request_id],
101
+ Messaging::ErrorCodes::ORIGIN_NOT_ALLOWED,
102
+ "Origin not allowed: #{origin}")
103
+ return
104
+ end
105
+
106
+ # Store the request for completion after PIN entry
107
+ @current_signing_request = {
108
+ message: message,
109
+ sender: sender,
110
+ send_response: send_response,
111
+ payload: payload
112
+ }
113
+
114
+ # Open popup for PIN entry
115
+ open_pin_popup
116
+ end
117
+
118
+ def handle_verify_request(message, sender, send_response)
119
+ payload = message[:payload] || `message.payload`
120
+
121
+ # Verification doesn't need PIN, send directly to native host
122
+ ensure_native_connection do |error|
123
+ if error
124
+ send_error(send_response, message[:request_id],
125
+ Messaging::ErrorCodes::NATIVE_HOST_NOT_FOUND, error)
126
+ return
127
+ end
128
+
129
+ native_message = {
130
+ type: "verify",
131
+ requestId: message[:request_id] || `message.requestId`,
132
+ payload: {
133
+ pdfData: payload[:pdf_data] || `payload.pdfData`,
134
+ checkTimestamp: payload[:check_timestamp] != false
135
+ }
136
+ }
137
+
138
+ send_to_native(native_message, send_response)
139
+ end
140
+ end
141
+
142
+ def handle_availability_request(send_response)
143
+ ensure_native_connection do |error|
144
+ if error
145
+ # Native host not available, but extension is
146
+ response = Messaging.create_response(
147
+ nil,
148
+ Messaging::Types::AVAILABILITY_RESPONSE,
149
+ { available: false, nativeHostInstalled: false, error: error }
150
+ )
151
+ `sendResponse(#{response.to_n})`
152
+ return
153
+ end
154
+
155
+ native_message = { type: "check_availability", requestId: Messaging.generate_request_id }
156
+ send_to_native(native_message, send_response)
157
+ end
158
+ end
159
+
160
+ def handle_cancel_request(request_id, send_response)
161
+ if @current_signing_request && @current_signing_request[:message][:request_id] == request_id
162
+ cancel_current_signing
163
+ end
164
+
165
+ response = Messaging.create_response(request_id, "cancel_response", { cancelled: true })
166
+ `sendResponse(#{response.to_n})`
167
+ end
168
+
169
+ def complete_signing_with_pin(pin)
170
+ return unless @current_signing_request
171
+
172
+ request = @current_signing_request
173
+ message = request[:message]
174
+ payload = request[:payload]
175
+ send_response = request[:send_response]
176
+
177
+ ensure_native_connection do |error|
178
+ if error
179
+ send_error(send_response, message[:request_id],
180
+ Messaging::ErrorCodes::NATIVE_HOST_NOT_FOUND, error)
181
+ @current_signing_request = nil
182
+ return
183
+ end
184
+
185
+ native_message = {
186
+ type: "sign",
187
+ requestId: message[:request_id] || `message.requestId`,
188
+ payload: {
189
+ pdfData: payload[:pdf_data] || `payload.pdfData`,
190
+ pin: pin,
191
+ options: payload[:options] || `payload.options` || {}
192
+ }
193
+ }
194
+
195
+ send_to_native(native_message, send_response)
196
+ @current_signing_request = nil
197
+ end
198
+ end
199
+
200
+ def cancel_current_signing
201
+ return unless @current_signing_request
202
+
203
+ request = @current_signing_request
204
+ send_response = request[:send_response]
205
+ request_id = request[:message][:request_id]
206
+
207
+ send_error(send_response, request_id, Messaging::ErrorCodes::CANCELLED, "Operation cancelled by user")
208
+ @current_signing_request = nil
209
+ end
210
+
211
+ def send_signing_context_to_popup
212
+ return unless @popup_port && @current_signing_request
213
+
214
+ context = {
215
+ type: "signing_context",
216
+ origin: @current_signing_request[:sender] && `#{@current_signing_request[:sender]}.origin`,
217
+ options: @current_signing_request[:payload][:options] || {}
218
+ }
219
+
220
+ `#{@popup_port}.postMessage(#{context.to_n})`
221
+ end
222
+
223
+ def open_pin_popup
224
+ # Open extension popup programmatically
225
+ `chrome.action.openPopup().catch((e) => {
226
+ console.error('Failed to open popup:', e);
227
+ // Fallback: create a new window
228
+ chrome.windows.create({
229
+ url: 'popup/popup.html',
230
+ type: 'popup',
231
+ width: 400,
232
+ height: 300
233
+ });
234
+ })`
235
+ end
236
+
237
+ def ensure_native_connection(&block)
238
+ if @native_port
239
+ block.call(nil)
240
+ return
241
+ end
242
+
243
+ begin
244
+ @native_port = `chrome.runtime.connectNative(#{NATIVE_HOST_NAME})`
245
+
246
+ `#{@native_port}.onMessage.addListener((msg) => {
247
+ #{handle_native_message(`msg`)}
248
+ })`
249
+
250
+ `#{@native_port}.onDisconnect.addListener(() => {
251
+ #{handle_native_disconnect}
252
+ })`
253
+
254
+ # Small delay to ensure connection is established
255
+ `setTimeout(() => { #{block.call(nil)} }, 50)`
256
+ rescue => e
257
+ block.call("Failed to connect to native host: #{e.message}")
258
+ end
259
+ end
260
+
261
+ def send_to_native(message, send_response)
262
+ return unless @native_port
263
+
264
+ request_id = message[:requestId] || `message.requestId`
265
+ @pending_requests[request_id] = send_response
266
+
267
+ `#{@native_port}.postMessage(#{message.to_n})`
268
+
269
+ # Set timeout
270
+ `setTimeout(() => {
271
+ #{handle_request_timeout(request_id)}
272
+ }, #{Messaging::DEFAULT_TIMEOUT})`
273
+ end
274
+
275
+ def handle_native_message(message)
276
+ request_id = message[:requestId] || `message.requestId` || message[:request_id]
277
+ send_response = @pending_requests.delete(request_id)
278
+
279
+ return unless send_response
280
+
281
+ # Forward response to content script
282
+ `sendResponse(#{message.to_n})`
283
+ end
284
+
285
+ def handle_native_disconnect
286
+ error = `chrome.runtime.lastError?.message || 'Native host disconnected'`
287
+
288
+ @pending_requests.each do |request_id, send_response|
289
+ send_error(send_response, request_id, Messaging::ErrorCodes::NATIVE_HOST_NOT_FOUND, error)
290
+ end
291
+
292
+ @pending_requests.clear
293
+ @native_port = nil
294
+
295
+ # Cancel current signing request if any
296
+ cancel_current_signing
297
+ end
298
+
299
+ def handle_request_timeout(request_id)
300
+ send_response = @pending_requests.delete(request_id)
301
+ return unless send_response
302
+
303
+ send_error(send_response, request_id, Messaging::ErrorCodes::TIMEOUT, "Operation timed out")
304
+ end
305
+
306
+ def send_error(send_response, request_id, code, message)
307
+ response = Messaging.create_error(request_id, code, message)
308
+ `sendResponse(#{response.to_n})`
309
+ end
310
+
311
+ def origin_allowed?(origin)
312
+ # TODO: Make this configurable via extension options
313
+ # For now, allow localhost and https origins
314
+ return true if origin =~ /^https?:\/\/localhost/
315
+ return true if origin =~ /^https:\/\//
316
+
317
+ false
318
+ end
319
+ end
320
+ end
321
+
322
+ # Initialize background service worker
323
+ EasySign::Background.new
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+
4
+ require "native"
5
+ require "easy_sign/messaging"
6
+
7
+ # Content script for EasySign browser extension
8
+ # Bridges communication between web page and extension background
9
+ module EasySign
10
+ class ContentScript
11
+ def initialize
12
+ @pending_responses = {}
13
+ setup_page_listener
14
+ inject_api_script
15
+ puts "EasySign content script initialized"
16
+ end
17
+
18
+ def setup_page_listener
19
+ # Listen for messages from the page (inject.js)
20
+ `window.addEventListener('message', (event) => {
21
+ // Only accept messages from the same window
22
+ if (event.source !== window) return;
23
+
24
+ // Only accept messages targeted at the extension
25
+ if (!event.data || event.data.target !== #{Messaging::Targets::EXTENSION.to_n}) return;
26
+
27
+ #{handle_page_message(`event.data`, `event.origin`)}
28
+ })`
29
+ end
30
+
31
+ def handle_page_message(data, origin)
32
+ request_id = data[:request_id] || `data.requestId` || `data.request_id`
33
+ type = data[:type] || `data.type`
34
+
35
+ # Forward to background script
36
+ `chrome.runtime.sendMessage(#{data.to_n}, (response) => {
37
+ #{handle_background_response(`response`, request_id)}
38
+ })`
39
+ end
40
+
41
+ def handle_background_response(response, request_id)
42
+ # Check for chrome runtime errors
43
+ error = `chrome.runtime.lastError`
44
+ if error
45
+ response = Messaging.create_error(
46
+ request_id,
47
+ Messaging::ErrorCodes::INTERNAL_ERROR,
48
+ `error.message || 'Extension error'`
49
+ )
50
+ end
51
+
52
+ # Send response back to page
53
+ post_to_page(response)
54
+ end
55
+
56
+ def post_to_page(message)
57
+ page_message = (message || {}).merge(target: Messaging::Targets::PAGE)
58
+ `window.postMessage(#{page_message.to_n}, '*')`
59
+ end
60
+
61
+ def inject_api_script
62
+ # Inject the page-context script that exposes window.EasySign
63
+ `const script = document.createElement('script');
64
+ script.src = chrome.runtime.getURL('inject.js');
65
+ script.onload = function() {
66
+ this.remove();
67
+ };
68
+ (document.head || document.documentElement).appendChild(script);`
69
+ end
70
+ end
71
+ end
72
+
73
+ # Initialize content script
74
+ EasySign::ContentScript.new