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