EE-ID-verification 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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module EeIdVerification
6
+ # Authentication session model for Estonian ID card authentication workflow.
7
+ #
8
+ # This class represents an active authentication session during the two-phase
9
+ # authentication process used by Estonian ID cards. The authentication workflow
10
+ # is designed to be secure and user-friendly:
11
+ #
12
+ # 1. Session Creation: When authentication starts, a session is created with
13
+ # a unique ID and initial status of :waiting_for_pin
14
+ # 2. PIN Entry: User provides PIN1 which is verified directly on the card
15
+ # 3. Session Completion: Upon successful PIN verification, session status
16
+ # changes to :completed and personal data is extracted
17
+ #
18
+ # The session-based approach provides several benefits:
19
+ # - Separation of concerns between session management and authentication logic
20
+ # - Timeout handling to prevent indefinite waiting for PIN entry
21
+ # - State tracking for complex authentication workflows
22
+ # - Security through session expiration and cleanup
23
+ #
24
+ # Session states:
25
+ # - :waiting_for_pin - Session created, waiting for user PIN input
26
+ # - :completed - Authentication successful, personal data available
27
+ # - :failed - Authentication failed (wrong PIN, card error, etc.)
28
+ # - :expired - Session timed out waiting for PIN entry
29
+ #
30
+ # @example Creating and managing a session
31
+ # session = AuthenticationSession.new(
32
+ # id: SecureRandom.uuid,
33
+ # method: :digidoc_local,
34
+ # status: :waiting_for_pin,
35
+ # created_at: Time.now,
36
+ # expires_at: Time.now + 300 # 5 minutes
37
+ # )
38
+ #
39
+ # # Check if session is still valid
40
+ # if session.expired?
41
+ # puts "Session expired, please try again"
42
+ # end
43
+ class AuthenticationSession
44
+ # Session attributes for tracking authentication state
45
+ # @!attribute [rw] id
46
+ # @return [String] Unique session identifier (typically UUID)
47
+ # @!attribute [rw] method
48
+ # @return [Symbol] Authentication method used (:digidoc_local)
49
+ # @!attribute [rw] status
50
+ # @return [Symbol] Current session status (:waiting_for_pin, :completed, :failed, :expired)
51
+ # @!attribute [rw] created_at
52
+ # @return [Time] When the session was created
53
+ # @!attribute [rw] expires_at
54
+ # @return [Time, nil] When the session expires (nil for no expiration)
55
+ attr_accessor :id, :method, :status, :created_at, :expires_at
56
+
57
+ # Initialize a new authentication session with the provided attributes.
58
+ #
59
+ # This constructor uses a flexible attribute assignment approach that allows
60
+ # setting any of the session attributes through a hash. This pattern provides
61
+ # flexibility for different authentication scenarios while maintaining a
62
+ # clean interface.
63
+ #
64
+ # @param attributes [Hash] Session attributes to set
65
+ # @option attributes [String] :id Unique session identifier
66
+ # @option attributes [Symbol] :method Authentication method
67
+ # @option attributes [Symbol] :status Initial session status
68
+ # @option attributes [Time] :created_at Session creation time
69
+ # @option attributes [Time] :expires_at Session expiration time
70
+ # @example
71
+ # session = AuthenticationSession.new(
72
+ # id: "auth-123",
73
+ # status: :waiting_for_pin,
74
+ # created_at: Time.now
75
+ # )
76
+ def initialize(attributes = {})
77
+ # Dynamically set attributes using setter methods
78
+ # This approach ensures only valid attributes are set and leverages
79
+ # any custom setter logic that might be added in the future
80
+ attributes.each do |key, value|
81
+ send("#{key}=", value) if respond_to?("#{key}=")
82
+ end
83
+ end
84
+
85
+ # Check if the authentication session has expired.
86
+ #
87
+ # Sessions expire to prevent security issues from long-lived authentication
88
+ # attempts and to free up system resources. An expired session cannot be
89
+ # used to complete authentication and should be discarded.
90
+ #
91
+ # The expiration check handles cases where no expiration time is set
92
+ # (nil expires_at means the session never expires automatically).
93
+ #
94
+ # @return [Boolean] true if session has expired, false otherwise
95
+ # @example
96
+ # session = AuthenticationSession.new(expires_at: Time.now - 60)
97
+ # puts "Expired!" if session.expired?
98
+ def expired?
99
+ expires_at && expires_at < Time.now
100
+ end
101
+ end
102
+
103
+ # Authentication result model containing the outcome of Estonian ID card authentication.
104
+ #
105
+ # This class encapsulates all information returned from a completed (or failed)
106
+ # authentication attempt. It provides a comprehensive view of the authentication
107
+ # outcome including:
108
+ #
109
+ # - Authentication status and success/failure indicators
110
+ # - Personal information extracted from the ID card certificate
111
+ # - Error information if authentication failed
112
+ # - Convenient methods for checking authentication state
113
+ #
114
+ # The result object serves as the primary interface between the authentication
115
+ # system and consuming applications, providing all necessary information to
116
+ # make authorization decisions and display user information.
117
+ #
118
+ # Personal data fields match those available in Estonian ID card certificates:
119
+ # - personal_code: 11-digit Estonian personal identification code
120
+ # - given_name: User's first/given name(s)
121
+ # - surname: User's family/last name
122
+ # - country: Country code (always "EE" for Estonian cards)
123
+ #
124
+ # @example Successful authentication result
125
+ # result = AuthenticationResult.new(
126
+ # session_id: "auth-123",
127
+ # status: :completed,
128
+ # authenticated: true,
129
+ # personal_code: "38001010008",
130
+ # given_name: "MARI",
131
+ # surname: "MAASIKAS",
132
+ # country: "EE"
133
+ # )
134
+ #
135
+ # if result.success?
136
+ # puts "Welcome, #{result.full_name}!"
137
+ # log_successful_login(result.personal_code)
138
+ # end
139
+ #
140
+ # @example Failed authentication result
141
+ # result = AuthenticationResult.new(
142
+ # session_id: "auth-456",
143
+ # status: :failed,
144
+ # authenticated: false,
145
+ # error: "Invalid PIN1"
146
+ # )
147
+ #
148
+ # if result.failure?
149
+ # puts "Authentication failed: #{result.error}"
150
+ # end
151
+ class AuthenticationResult
152
+ # Result attributes containing authentication outcome and personal data
153
+ # @!attribute [rw] session_id
154
+ # @return [String] ID of the session this result belongs to
155
+ # @!attribute [rw] status
156
+ # @return [Symbol] Final authentication status (:completed, :failed, etc.)
157
+ # @!attribute [rw] authenticated
158
+ # @return [Boolean] Whether authentication was successful
159
+ # @!attribute [rw] error
160
+ # @return [String, nil] Error message if authentication failed
161
+ # @!attribute [rw] personal_code
162
+ # @return [String, nil] Estonian 11-digit personal identification code
163
+ # @!attribute [rw] given_name
164
+ # @return [String, nil] User's first/given name from certificate
165
+ # @!attribute [rw] surname
166
+ # @return [String, nil] User's family/last name from certificate
167
+ # @!attribute [rw] country
168
+ # @return [String, nil] Country code from certificate (typically "EE")
169
+ attr_accessor :session_id, :status, :authenticated, :error,
170
+ :personal_code, :given_name, :surname, :country
171
+
172
+ # Initialize a new authentication result with the provided attributes.
173
+ #
174
+ # Similar to AuthenticationSession, this uses flexible attribute assignment
175
+ # to allow setting result fields through a hash. The authenticated flag
176
+ # defaults to false to ensure secure defaults - authentication must be
177
+ # explicitly marked as successful.
178
+ #
179
+ # @param attributes [Hash] Result attributes to set
180
+ # @option attributes [String] :session_id Associated session ID
181
+ # @option attributes [Symbol] :status Authentication status
182
+ # @option attributes [Boolean] :authenticated Success flag
183
+ # @option attributes [String] :error Error message for failures
184
+ # @option attributes [String] :personal_code Estonian personal code
185
+ # @option attributes [String] :given_name User's first name
186
+ # @option attributes [String] :surname User's last name
187
+ # @option attributes [String] :country Country code
188
+ # @example
189
+ # result = AuthenticationResult.new(
190
+ # authenticated: true,
191
+ # personal_code: "38001010008"
192
+ # )
193
+ def initialize(attributes = {})
194
+ # Set provided attributes using dynamic attribute assignment
195
+ attributes.each do |key, value|
196
+ send("#{key}=", value) if respond_to?("#{key}=")
197
+ end
198
+
199
+ # Ensure authenticated defaults to false for security
200
+ # Authentication must be explicitly set to true to be considered successful
201
+ @authenticated ||= false
202
+ end
203
+
204
+ # Check if the user was successfully authenticated.
205
+ #
206
+ # This is the primary method for determining authentication success.
207
+ # It directly returns the authenticated flag which should only be true
208
+ # if PIN verification succeeded and personal data was extracted.
209
+ #
210
+ # @return [Boolean] true if authentication was successful
211
+ def authenticated?
212
+ @authenticated
213
+ end
214
+
215
+ # Check if authentication was successful and no errors occurred.
216
+ #
217
+ # This method provides a more comprehensive success check than authenticated?
218
+ # by also ensuring no error occurred during the process. This catches cases
219
+ # where authentication might be marked as successful but an error was
220
+ # encountered during personal data extraction.
221
+ #
222
+ # @return [Boolean] true if authenticated and no error present
223
+ # @example
224
+ # if result.success?
225
+ # grant_access(result.personal_code)
226
+ # else
227
+ # show_error_message(result.error)
228
+ # end
229
+ def success?
230
+ authenticated? && !error
231
+ end
232
+
233
+ # Check if authentication failed or encountered an error.
234
+ #
235
+ # This is the inverse of success? and provides a convenient way to check
236
+ # for any kind of authentication failure. Useful for error handling and
237
+ # conditional logic in authentication flows.
238
+ #
239
+ # @return [Boolean] true if authentication failed or error occurred
240
+ def failure?
241
+ !success?
242
+ end
243
+
244
+ # Get the user's full name by combining given name and surname.
245
+ #
246
+ # Estonian ID certificates store names in separate fields (given name and
247
+ # surname) following international X.509 certificate standards. This method
248
+ # provides a convenient way to get the complete name for display purposes.
249
+ #
250
+ # The method handles various edge cases:
251
+ # - Missing given name or surname (returns partial name)
252
+ # - Both names missing (returns nil)
253
+ # - Extra whitespace (cleaned up by join)
254
+ #
255
+ # @return [String, nil] Full name or nil if no name components available
256
+ # @example
257
+ # result.given_name = "MARI"
258
+ # result.surname = "MAASIKAS"
259
+ # puts result.full_name # => "MARI MAASIKAS"
260
+ #
261
+ # result.given_name = nil
262
+ # result.surname = "MAASIKAS"
263
+ # puts result.full_name # => "MAASIKAS"
264
+ def full_name
265
+ # Return nil if neither name component is available
266
+ return nil unless given_name || surname
267
+
268
+ # Combine available name components, filtering out nil values
269
+ # compact removes nil values, join combines with space
270
+ [given_name, surname].compact.join(" ")
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EeIdVerification
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "ee_id_verification/version"
5
+ require_relative "ee_id_verification/certificate_reader"
6
+ require_relative "ee_id_verification/models"
7
+
8
+ # Estonian ID card authentication library.
9
+ #
10
+ # Simple Ruby interface for authenticating users with Estonian ID cards
11
+ # using local card readers. Focuses on DigiDoc Local authentication only.
12
+ #
13
+ # ## Basic Usage
14
+ #
15
+ # require 'ee_id_verification'
16
+ #
17
+ # # Create verifier
18
+ # verifier = EeIdVerification.new
19
+ #
20
+ # # Check if ID card is available
21
+ # if verifier.available?
22
+ # # Authenticate user
23
+ # session = verifier.authenticate
24
+ # puts "Enter PIN1: "
25
+ # pin = gets.chomp
26
+ #
27
+ # result = verifier.complete_authentication(session, pin)
28
+ # puts "Welcome #{result.full_name}!" if result.success?
29
+ # end
30
+ #
31
+ module EeIdVerification
32
+ # Generic error class for the gem.
33
+ class Error < StandardError; end
34
+
35
+ # Main verifier class
36
+ class Verifier
37
+ # Initialize the verifier
38
+ def initialize
39
+ @reader = CertificateReader.new
40
+ @sessions = {}
41
+ end
42
+
43
+ # Check if Estonian ID card authentication is available
44
+ # @return [Boolean] true if card and reader are present
45
+ def available?
46
+ @reader.card_present?
47
+ end
48
+
49
+ # Start authentication process
50
+ # @return [AuthenticationSession] session for PIN completion
51
+ def authenticate
52
+ raise Error, "No Estonian ID card detected" unless available?
53
+
54
+ session = AuthenticationSession.new(
55
+ id: SecureRandom.hex(16),
56
+ method: :digidoc_local,
57
+ status: :waiting_for_pin,
58
+ created_at: Time.now,
59
+ expires_at: Time.now + 300
60
+ )
61
+
62
+ @sessions[session.id] = session
63
+ session
64
+ end
65
+
66
+ # Complete authentication with PIN
67
+ # @param session [AuthenticationSession] session from authenticate
68
+ # @param pin [String] user's PIN1
69
+ # @return [AuthenticationResult] final result
70
+ def complete_authentication(session, pin)
71
+ stored_session = @sessions[session.id]
72
+ return failed_result(session.id, "Session not found") unless stored_session
73
+ return failed_result(session.id, "Session expired") if session.expired?
74
+
75
+ begin
76
+ @reader.connect
77
+ cert = @reader.read_auth_certificate(pin)
78
+ personal_data = @reader.extract_personal_data(cert)
79
+
80
+ @sessions.delete(session.id)
81
+
82
+ AuthenticationResult.new(
83
+ session_id: session.id,
84
+ status: :completed,
85
+ authenticated: true,
86
+ personal_code: personal_data[:personal_code],
87
+ given_name: personal_data[:given_name],
88
+ surname: personal_data[:surname],
89
+ country: personal_data[:country]
90
+ )
91
+ rescue StandardError => e
92
+ failed_result(session.id, e.message)
93
+ ensure
94
+ begin
95
+ @reader.disconnect
96
+ rescue StandardError
97
+ nil
98
+ end
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def failed_result(session_id, error_message)
105
+ AuthenticationResult.new(
106
+ session_id: session_id,
107
+ status: :failed,
108
+ authenticated: false,
109
+ error: error_message
110
+ )
111
+ end
112
+ end
113
+
114
+ # Module-level convenience method
115
+ def self.new
116
+ Verifier.new
117
+ end
118
+ end
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ee_id_verification"
5
+ require "openssl"
6
+
7
+ def print_section(title)
8
+ puts "\n#{title}"
9
+ puts "=" * title.length
10
+ end
11
+
12
+ def print_subsection(title)
13
+ puts "\n#{title}:"
14
+ puts "-" * (title.length + 1)
15
+ end
16
+
17
+ def format_certificate_field(name, value)
18
+ if value.nil? || value.to_s.strip.empty?
19
+ puts " #{name}: (not available)"
20
+ else
21
+ puts " #{name}: #{value}"
22
+ end
23
+ end
24
+
25
+ def get_card_hardware_info
26
+ begin
27
+ # Get PKCS#11 library and slots directly
28
+ library = EeIdVerification::CertificateReader.shared_pkcs11_library
29
+ return { error: "PKCS#11 library not available" } unless library
30
+
31
+ slots = library.slots(true)
32
+ esteid_slots = slots.select do |slot|
33
+ begin
34
+ token_info = slot.token_info
35
+ label = token_info.label.strip
36
+ manufacturer = token_info.manufacturerID.strip
37
+
38
+ label.include?("ESTEID") ||
39
+ manufacturer.include?("SK") ||
40
+ label.match?(/PIN[12]/) ||
41
+ label.include?("Isikutuvastus")
42
+ rescue
43
+ false
44
+ end
45
+ end
46
+
47
+ return { error: "No Estonian ID card slots found" } if esteid_slots.empty?
48
+
49
+ slot = esteid_slots.first
50
+ token_info = slot.token_info
51
+
52
+ {
53
+ token_label: token_info.label.strip,
54
+ token_manufacturer: token_info.manufacturerID.strip,
55
+ token_model: token_info.model.strip,
56
+ token_serial: token_info.serialNumber.strip,
57
+ slot_description: "Slot #{slot}"
58
+ }
59
+ rescue => e
60
+ { error: e.message }
61
+ end
62
+ end
63
+
64
+ puts "Estonian ID Card Test & Information Export"
65
+ puts "=========================================="
66
+
67
+ verifier = EeIdVerification.new
68
+ reader = EeIdVerification::CertificateReader.new
69
+
70
+ unless verifier.available?
71
+ puts "❌ No Estonian ID card detected"
72
+ puts "Please insert your ID card and ensure reader is connected"
73
+ exit 1
74
+ end
75
+
76
+ puts "✅ Estonian ID card detected"
77
+
78
+ # Get hardware information before authentication
79
+ print_section("Card Reader & Hardware Information")
80
+ hardware_info = get_card_hardware_info
81
+
82
+ if hardware_info[:error]
83
+ puts "⚠️ Could not retrieve hardware info: #{hardware_info[:error]}"
84
+ else
85
+ format_certificate_field("Token Label", hardware_info[:token_label])
86
+ format_certificate_field("Token Manufacturer", hardware_info[:token_manufacturer])
87
+ format_certificate_field("Token Model", hardware_info[:token_model])
88
+ format_certificate_field("Token Serial Number", hardware_info[:token_serial])
89
+ format_certificate_field("Slot Description", hardware_info[:slot_description])
90
+ end
91
+
92
+ # Proceed with authentication
93
+ session = verifier.authenticate
94
+ puts "\n🔑 Enter your PIN1: "
95
+ pin = gets.chomp
96
+
97
+ result = verifier.complete_authentication(session, pin)
98
+
99
+ if result.success?
100
+ print_section("🎉 Authentication Successful!")
101
+
102
+ print_subsection("Basic Identity Information")
103
+ format_certificate_field("Full Name", result.full_name)
104
+ format_certificate_field("Given Name", result.given_name)
105
+ format_certificate_field("Surname", result.surname)
106
+ format_certificate_field("Personal Code", result.personal_code)
107
+ format_certificate_field("Country", result.country)
108
+
109
+ # Parse personal code for detailed demographics
110
+ if result.personal_code
111
+ personal_info = reader.parse_personal_code(result.personal_code)
112
+
113
+ if personal_info && !personal_info.empty?
114
+ print_subsection("Demographic Information")
115
+ format_certificate_field("Birth Date", personal_info[:birth_date])
116
+ format_certificate_field("Gender", personal_info[:gender])
117
+ format_certificate_field("Age", "#{personal_info[:age]} years")
118
+
119
+ # Calculate additional demographics
120
+ if personal_info[:birth_date]
121
+ birth_year = personal_info[:birth_date].year
122
+ generation = case birth_year
123
+ when 1946..1964 then "Baby Boomer"
124
+ when 1965..1980 then "Generation X"
125
+ when 1981..1996 then "Millennial"
126
+ when 1997..2012 then "Generation Z"
127
+ else "Generation Alpha"
128
+ end
129
+ format_certificate_field("Generation", generation)
130
+
131
+ # Century calculation
132
+ century_code = result.personal_code[0].to_i
133
+ century_info = case century_code
134
+ when 1, 2 then "19th century (1800-1899)"
135
+ when 3, 4 then "20th century (1900-1999)"
136
+ when 5, 6 then "21st century (2000-2099)"
137
+ when 7, 8 then "22nd century (2100-2199)"
138
+ else "Unknown century"
139
+ end
140
+ format_certificate_field("Birth Century", century_info)
141
+ end
142
+ end
143
+ end
144
+
145
+ # Get detailed certificate information
146
+ begin
147
+ reader.connect
148
+ certificate = reader.read_auth_certificate(pin)
149
+
150
+ print_subsection("Certificate Details")
151
+ format_certificate_field("Certificate Type", "Authentication Certificate (PIN1)")
152
+ format_certificate_field("Serial Number", certificate.serial.to_s)
153
+ format_certificate_field("Version", "v#{certificate.version}")
154
+ format_certificate_field("Valid From", certificate.not_before.strftime("%Y-%m-%d %H:%M:%S UTC"))
155
+ format_certificate_field("Valid Until", certificate.not_after.strftime("%Y-%m-%d %H:%M:%S UTC"))
156
+
157
+ # Check if certificate is still valid
158
+ now = Time.now
159
+ if now < certificate.not_before
160
+ format_certificate_field("Status", "⚠️ Not yet valid")
161
+ elsif now > certificate.not_after
162
+ format_certificate_field("Status", "❌ Expired")
163
+ else
164
+ days_until_expiry = ((certificate.not_after - now) / (24 * 60 * 60)).to_i
165
+ format_certificate_field("Status", "✅ Valid (expires in #{days_until_expiry} days)")
166
+ end
167
+
168
+ print_subsection("Certificate Issuer")
169
+ issuer_parts = certificate.issuer.to_a.to_h { |part| [part[0], part[1]] }
170
+ format_certificate_field("Common Name", issuer_parts["CN"])
171
+ format_certificate_field("Organization", issuer_parts["O"])
172
+ format_certificate_field("Country", issuer_parts["C"])
173
+ format_certificate_field("Email", issuer_parts["emailAddress"])
174
+
175
+ print_subsection("Certificate Subject")
176
+ subject_parts = certificate.subject.to_a.to_h { |part| [part[0], part[1]] }
177
+ format_certificate_field("Common Name", subject_parts["CN"])
178
+ format_certificate_field("Given Name", subject_parts["GN"] || subject_parts["givenName"])
179
+ format_certificate_field("Surname", subject_parts["SN"] || subject_parts["surname"])
180
+ format_certificate_field("Serial Number", subject_parts["serialNumber"])
181
+ format_certificate_field("Country", subject_parts["C"])
182
+ format_certificate_field("Organization", subject_parts["O"])
183
+ format_certificate_field("Organizational Unit", subject_parts["OU"])
184
+
185
+ print_subsection("Cryptographic Information")
186
+ public_key = certificate.public_key
187
+ format_certificate_field("Algorithm", certificate.signature_algorithm)
188
+ format_certificate_field("Public Key Type", public_key.class.name.split("::").last)
189
+
190
+ if public_key.respond_to?(:n) # RSA key
191
+ key_size = public_key.n.to_s(2).length
192
+ format_certificate_field("Key Size", "#{key_size} bits")
193
+ format_certificate_field("Exponent", public_key.e.to_s)
194
+ end
195
+
196
+ print_subsection("Certificate Extensions")
197
+ certificate.extensions.each do |ext|
198
+ case ext.oid
199
+ when "keyUsage"
200
+ format_certificate_field("Key Usage", ext.value)
201
+ when "extendedKeyUsage"
202
+ format_certificate_field("Extended Key Usage", ext.value)
203
+ when "subjectKeyIdentifier"
204
+ format_certificate_field("Subject Key ID", ext.value.gsub(":", " "))
205
+ when "authorityKeyIdentifier"
206
+ # Parse the authority key identifier
207
+ aki_value = ext.value.gsub("keyid:", "").split("\n").first
208
+ format_certificate_field("Authority Key ID", aki_value.gsub(":", " "))
209
+ when "certificatePolicies"
210
+ format_certificate_field("Certificate Policies", ext.value)
211
+ when "crlDistributionPoints"
212
+ format_certificate_field("CRL Distribution", ext.value.split("\n").join(", "))
213
+ when "authorityInfoAccess"
214
+ format_certificate_field("Authority Info Access", ext.value.split("\n").join(", "))
215
+ else
216
+ format_certificate_field(ext.oid, ext.value) if ext.value.length < 100
217
+ end
218
+ end
219
+
220
+ print_subsection("Certificate Fingerprints")
221
+ cert_der = certificate.to_der
222
+ format_certificate_field("SHA-1", OpenSSL::Digest::SHA1.hexdigest(cert_der).upcase.scan(/.{2}/).join(":"))
223
+ format_certificate_field("SHA-256", OpenSSL::Digest::SHA256.hexdigest(cert_der).upcase.scan(/.{2}/).join(":"))
224
+ format_certificate_field("MD5", OpenSSL::Digest::MD5.hexdigest(cert_der).upcase.scan(/.{2}/).join(":"))
225
+
226
+ rescue => e
227
+ puts "\n⚠️ Could not retrieve detailed certificate information: #{e.message}"
228
+ ensure
229
+ reader.disconnect rescue nil
230
+ end
231
+
232
+ else
233
+ puts "\n❌ Authentication failed: #{result.error}"
234
+
235
+ # Provide helpful error guidance
236
+ case result.error
237
+ when /PIN/i
238
+ puts "\nTroubleshooting:"
239
+ puts "- Verify you're using PIN1 (not PIN2 for signing)"
240
+ puts "- Check if PIN is blocked (3 failed attempts)"
241
+ puts "- Use DigiDoc4 client to unblock PIN if needed"
242
+ when /card/i
243
+ puts "\nTroubleshooting:"
244
+ puts "- Ensure card is properly inserted"
245
+ puts "- Check card reader connection"
246
+ puts "- Try removing and reinserting the card"
247
+ when /certificate/i
248
+ puts "\nTroubleshooting:"
249
+ puts "- Your card may be expired or damaged"
250
+ puts "- Contact Estonian ID-card support: +372 677 3377"
251
+ end
252
+ end
253
+
254
+ puts "\n" + "=" * 50
255
+ puts "Test completed at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"