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.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ desc "Run tests"
6
+ task :test do
7
+ sh "ruby -Ilib:test test/ee_id_verification_test.rb"
8
+ end
9
+
10
+ require "rubocop/rake_task"
11
+ RuboCop::RakeTask.new
12
+
13
+ task default: %i[test rubocop]
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "pkcs11"
5
+ require "date"
6
+
7
+ module EeIdVerification
8
+ # Estonian ID card certificate reader using PKCS#11 interface.
9
+ #
10
+ # This class provides secure access to Estonian ID cards through the industry-standard
11
+ # PKCS#11 cryptographic interface. It handles all the low-level complexity of:
12
+ # - PKCS#11 library loading and initialization
13
+ # - Smart card slot detection and enumeration
14
+ # - Estonian ID card identification and connection
15
+ # - Certificate reading and X.509 parsing
16
+ # - Personal data extraction from certificate fields
17
+ # - Estonian personal code parsing and validation
18
+ #
19
+ # The implementation uses OpenSC (Open Smart Card development libraries) which
20
+ # provides PKCS#11 drivers for Estonian ID cards. This is the recommended and
21
+ # officially supported method for accessing Estonian e-identity infrastructure.
22
+ #
23
+ # Security considerations:
24
+ # - Uses hardware security module (HSM) on the card for all cryptographic operations
25
+ # - PIN verification is performed directly on the card, PIN never leaves the card
26
+ # - Certificates are read from secure storage on the card
27
+ # - Supports shared PKCS#11 library instance to avoid conflicts with other applications
28
+ #
29
+ # @example Basic usage
30
+ # reader = CertificateReader.new
31
+ # if reader.card_present?
32
+ # reader.connect
33
+ # cert = reader.read_auth_certificate("1234") # PIN1
34
+ # personal_data = reader.extract_personal_data(cert)
35
+ # puts "Hello #{personal_data[:given_name]}!"
36
+ # reader.disconnect
37
+ # end
38
+ class CertificateReader
39
+ # Common PKCS#11 library locations across different operating systems.
40
+ # These paths are searched in order to find the OpenSC PKCS#11 library.
41
+ # OpenSC provides drivers for Estonian ID cards and many other smart cards.
42
+ #
43
+ # Path priority:
44
+ # 1. Homebrew installations on macOS (most common for developers)
45
+ # 2. System installations on Linux
46
+ # 3. Alternative installation locations
47
+ PKCS11_LIBRARY_PATHS = [
48
+ "/opt/homebrew/lib/opensc-pkcs11.so", # Homebrew on Apple Silicon
49
+ "/opt/homebrew/lib/pkcs11/opensc-pkcs11.so", # Alternative Homebrew path
50
+ "/usr/local/lib/opensc-pkcs11.so", # Homebrew on Intel Mac
51
+ "/usr/lib/opensc-pkcs11.so", # Ubuntu/Debian system install
52
+ "/opt/local/lib/opensc-pkcs11.so", # MacPorts installation
53
+ "/System/Library/OpenSC/lib/opensc-pkcs11.so", # macOS system install
54
+ "/usr/local/lib/pkcs11/opensc-pkcs11.so" # Alternative Linux path
55
+ ].freeze
56
+
57
+ # Initialize a new certificate reader instance.
58
+ # Sets up internal state but does not connect to any card or load PKCS#11 library.
59
+ # Connection and library loading happens lazily when needed.
60
+ def initialize
61
+ @pkcs11 = nil # PKCS#11 library instance (loaded on demand)
62
+ @slot = nil # Smart card slot containing Estonian ID card
63
+ @session = nil # Active PKCS#11 session for card communication
64
+ @connected = false # Connection state flag
65
+ end
66
+
67
+ # Check if Estonian ID card is present in any connected card reader.
68
+ #
69
+ # This method performs card detection by:
70
+ # 1. Loading the PKCS#11 library if not already loaded
71
+ # 2. Enumerating all smart card slots with tokens present
72
+ # 3. Checking each slot's token info for Estonian ID card markers
73
+ # 4. Returning true if at least one Estonian ID card is found
74
+ #
75
+ # The detection is safe and non-intrusive - it doesn't require PIN entry
76
+ # or establish any connections to the card.
77
+ #
78
+ # @return [Boolean] true if Estonian ID card is detected, false otherwise
79
+ # @example
80
+ # reader = CertificateReader.new
81
+ # if reader.card_present?
82
+ # puts "Estonian ID card detected!"
83
+ # else
84
+ # puts "Please insert your Estonian ID card"
85
+ # end
86
+ def card_present?
87
+ load_pkcs11_library
88
+ return false unless @pkcs11
89
+
90
+ # Get all slots that currently have tokens (cards) inserted
91
+ slots = @pkcs11.slots(true)
92
+ esteid_slots = find_esteid_slots(slots)
93
+ !esteid_slots.empty?
94
+ rescue StandardError
95
+ # If anything goes wrong during detection, assume no card present
96
+ # This prevents crashes when card readers are disconnected, etc.
97
+ false
98
+ end
99
+
100
+ # Connect to the first available Estonian ID card.
101
+ #
102
+ # This method establishes a connection to an Estonian ID card by:
103
+ # 1. Loading the PKCS#11 library
104
+ # 2. Finding all slots with Estonian ID cards
105
+ # 3. Selecting the first available card
106
+ # 4. Preparing for future certificate reading operations
107
+ #
108
+ # Note: This method does not require PIN entry - it only establishes
109
+ # the connection to the card. PIN will be required later when reading
110
+ # certificates that require authentication.
111
+ #
112
+ # @return [Boolean] true if connection successful
113
+ # @raise [RuntimeError] if PKCS#11 library not available
114
+ # @raise [RuntimeError] if no Estonian ID card found
115
+ # @example
116
+ # reader = CertificateReader.new
117
+ # reader.connect
118
+ # puts "Connected to Estonian ID card"
119
+ def connect
120
+ load_pkcs11_library
121
+ raise "PKCS#11 library not available" unless @pkcs11
122
+
123
+ # Find all slots with tokens and filter for Estonian ID cards
124
+ slots = @pkcs11.slots(true)
125
+ esteid_slots = find_esteid_slots(slots)
126
+ raise "No Estonian ID card found" if esteid_slots.empty?
127
+
128
+ # Use the first available Estonian ID card
129
+ # In most cases users have only one card reader anyway
130
+ @slot = esteid_slots.first
131
+ @connected = true
132
+ true
133
+ end
134
+
135
+ # Disconnect from the Estonian ID card and clean up resources.
136
+ #
137
+ # This method performs a clean shutdown by:
138
+ # 1. Logging out from any active PKCS#11 sessions
139
+ # 2. Closing all open sessions
140
+ # 3. Cleaning up PKCS#11 library resources
141
+ # 4. Resetting internal state
142
+ #
143
+ # It's important to disconnect properly to:
144
+ # - Free the card for use by other applications
145
+ # - Prevent resource leaks
146
+ # - Ensure security by ending authenticated sessions
147
+ #
148
+ # All operations are wrapped in error handling to ensure cleanup
149
+ # happens even if individual steps fail.
150
+ def disconnect
151
+ # Attempt to logout from PKCS#11 session (clears authentication state)
152
+ begin
153
+ @session&.logout
154
+ rescue StandardError
155
+ nil # Ignore logout errors - session might not be authenticated
156
+ end
157
+
158
+ # Close the PKCS#11 session
159
+ begin
160
+ @session&.close
161
+ rescue StandardError
162
+ nil # Ignore close errors - session might already be closed
163
+ end
164
+
165
+ # Reset session and connection state
166
+ @session = nil
167
+ @connected = false
168
+
169
+ # Close the PKCS#11 library
170
+ begin
171
+ @pkcs11&.close
172
+ rescue StandardError
173
+ nil # Ignore close errors - library might already be closed
174
+ end
175
+
176
+ # Reset library state
177
+ @pkcs11 = nil
178
+ end
179
+
180
+ # Check if currently connected to an Estonian ID card.
181
+ #
182
+ # @return [Boolean] true if connected and ready for operations
183
+ def connected?
184
+ @connected && @pkcs11 && @slot
185
+ end
186
+
187
+ # Read authentication certificate from Estonian ID card using PIN1.
188
+ #
189
+ # This method performs secure certificate reading by:
190
+ # 1. Ensuring we're connected to a card
191
+ # 2. Opening a PKCS#11 session with the card
192
+ # 3. Authenticating with PIN1 (this happens on the card for security)
193
+ # 4. Locating the authentication certificate
194
+ # 5. Reading and parsing the X.509 certificate
195
+ #
196
+ # The authentication certificate is used for identity verification and
197
+ # contains the user's personal information in the certificate subject.
198
+ #
199
+ # Security notes:
200
+ # - PIN verification happens directly on the card's secure element
201
+ # - PIN is never transmitted or stored in memory
202
+ # - Failed PIN attempts are tracked by the card (3 attempts max)
203
+ # - After 3 failed attempts, PIN1 becomes blocked
204
+ #
205
+ # @param pin [String] User's PIN1 (typically 4 digits, but can be longer)
206
+ # @return [OpenSSL::X509::Certificate] The authentication certificate
207
+ # @raise [RuntimeError] if not connected to card
208
+ # @raise [RuntimeError] if PIN is incorrect
209
+ # @raise [RuntimeError] if PIN is blocked
210
+ # @raise [RuntimeError] if certificate not found
211
+ # @example
212
+ # reader = CertificateReader.new
213
+ # reader.connect
214
+ # cert = reader.read_auth_certificate("1234")
215
+ # puts "Certificate valid until: #{cert.not_after}"
216
+ def read_auth_certificate(pin)
217
+ ensure_connected!
218
+
219
+ # Open a session with the card if not already open
220
+ @session ||= @slot.open
221
+
222
+ # Authenticate with PIN1 - this happens securely on the card
223
+ @session.login(PKCS11::CKU_USER, pin)
224
+
225
+ # Find the authentication certificate among all certificates on card
226
+ cert = find_auth_certificate
227
+ raise "Authentication certificate not found" unless cert
228
+
229
+ cert
230
+ rescue PKCS11::CKR_PIN_INCORRECT
231
+ raise "Invalid PIN1"
232
+ rescue PKCS11::CKR_PIN_LOCKED
233
+ raise "PIN1 is blocked"
234
+ end
235
+
236
+ # Extract personal data from an Estonian ID card certificate.
237
+ #
238
+ # Estonian ID certificates contain personal information in the X.509
239
+ # certificate subject fields using standard and Estonian-specific field names:
240
+ # - GN/givenName: Given name (first name)
241
+ # - SN/surname: Surname (family name)
242
+ # - serialNumber: Personal identification code (with PNOEE- prefix)
243
+ # - C/countryName: Country code (always "EE" for Estonian cards)
244
+ # - CN/commonName: Full name
245
+ #
246
+ # @param certificate [OpenSSL::X509::Certificate] X.509 certificate from card
247
+ # @return [Hash] Personal data with symbolized keys
248
+ # @option return [String] :given_name User's first name
249
+ # @option return [String] :surname User's family name
250
+ # @option return [String] :personal_code 11-digit Estonian personal code
251
+ # @option return [String] :country Country code ("EE")
252
+ # @option return [String] :common_name Full name as on certificate
253
+ # @example
254
+ # cert = reader.read_auth_certificate("1234")
255
+ # data = reader.extract_personal_data(cert)
256
+ # puts "Name: #{data[:given_name]} #{data[:surname]}"
257
+ # puts "Personal code: #{data[:personal_code]}"
258
+ def extract_personal_data(certificate)
259
+ # Parse certificate subject into a hash for easy access
260
+ # X.509 subject is an array of [OID, value, type] arrays
261
+ subject = certificate.subject.to_a.to_h { |part| [part[0], part[1]] }
262
+
263
+ {
264
+ given_name: subject["GN"] || subject["givenName"],
265
+ surname: subject["SN"] || subject["surname"],
266
+ personal_code: extract_personal_code(subject["serialNumber"]),
267
+ country: subject["C"] || subject["countryName"],
268
+ common_name: subject["CN"] || subject["commonName"]
269
+ }
270
+ end
271
+
272
+ # Parse Estonian personal identification code for demographic information.
273
+ #
274
+ # Estonian personal codes are 11-digit numbers that encode:
275
+ # - Position 1: Century and gender (1-8)
276
+ # - Positions 2-3: Year of birth (00-99)
277
+ # - Positions 4-5: Month of birth (01-12)
278
+ # - Positions 6-7: Day of birth (01-31)
279
+ # - Positions 8-10: Serial number for same birth date
280
+ # - Position 11: Check digit
281
+ #
282
+ # Century and gender encoding:
283
+ # - 1,2: 1800-1899 (1=male, 2=female)
284
+ # - 3,4: 1900-1999 (3=male, 4=female)
285
+ # - 5,6: 2000-2099 (5=male, 6=female)
286
+ # - 7,8: 2100-2199 (7=male, 8=female)
287
+ #
288
+ # @param personal_code [String] 11-digit Estonian personal code
289
+ # @return [Hash] Parsed demographic information
290
+ # @option return [Date] :birth_date Calculated birth date
291
+ # @option return [String] :gender "Male" or "Female"
292
+ # @option return [Integer] :age Current age in years
293
+ # @example
294
+ # reader = CertificateReader.new
295
+ # info = reader.parse_personal_code("38001010008")
296
+ # puts "Born: #{info[:birth_date]} (#{info[:gender]}, age #{info[:age]})"
297
+ def parse_personal_code(personal_code)
298
+ # Validate format: exactly 11 digits
299
+ return {} unless personal_code&.match?(/^\d{11}$/)
300
+
301
+ # Extract components from the personal code
302
+ century_gender = personal_code[0].to_i # First digit: century and gender
303
+ year = personal_code[1..2].to_i # Year within century (00-99)
304
+ month = personal_code[3..4].to_i # Month (01-12)
305
+ day = personal_code[5..6].to_i # Day (01-31)
306
+
307
+ # Decode century and gender from first digit
308
+ century, gender = case century_gender
309
+ when 1, 2
310
+ [1800, century_gender == 1 ? "Male" : "Female"]
311
+ when 3, 4
312
+ [1900, century_gender == 3 ? "Male" : "Female"]
313
+ when 5, 6
314
+ [2000, century_gender == 5 ? "Male" : "Female"]
315
+ when 7, 8
316
+ [2100, century_gender == 7 ? "Male" : "Female"]
317
+ else
318
+ return {} # Invalid first digit
319
+ end
320
+
321
+ # Calculate full birth year and create date
322
+ birth_year = century + year
323
+ birth_date = Date.new(birth_year, month, day)
324
+
325
+ # Calculate current age accounting for whether birthday has passed this year
326
+ today = Date.today
327
+ age = today.year - birth_date.year
328
+ age -= 1 if today < Date.new(today.year, birth_date.month, birth_date.day)
329
+
330
+ {
331
+ birth_date: birth_date,
332
+ gender: gender,
333
+ age: age
334
+ }
335
+ rescue StandardError
336
+ # Return empty hash if date is invalid or any other error occurs
337
+ {}
338
+ end
339
+
340
+ private
341
+
342
+ # Ensure we're connected to a card before attempting operations.
343
+ # @raise [RuntimeError] if not connected
344
+ def ensure_connected!
345
+ raise "Not connected to card" unless connected?
346
+ end
347
+
348
+ # Load PKCS#11 library on demand using shared instance.
349
+ # Uses class-level shared library to avoid conflicts between instances.
350
+ def load_pkcs11_library
351
+ return if @pkcs11
352
+
353
+ @pkcs11 = self.class.shared_pkcs11_library
354
+ end
355
+
356
+ # Get shared PKCS#11 library instance across all CertificateReader instances.
357
+ #
358
+ # This prevents conflicts when multiple instances try to initialize the same
359
+ # PKCS#11 library, which can cause "already initialized" errors.
360
+ #
361
+ # @return [PKCS11, nil] Shared PKCS#11 library instance or nil if unavailable
362
+ def self.shared_pkcs11_library
363
+ @shared_pkcs11 ||= begin
364
+ library_path = PKCS11_LIBRARY_PATHS.find { |path| File.exist?(path) }
365
+ library_path ? PKCS11.open(library_path) : nil
366
+ end
367
+ rescue StandardError
368
+ # If library initialization fails (e.g., already initialized by another process),
369
+ # return nil to gracefully handle the error
370
+ nil
371
+ end
372
+
373
+ # Find slots containing Estonian ID cards among all available smart card slots.
374
+ #
375
+ # Estonian ID cards can be identified by examining token information:
376
+ # - Label contains "ESTEID" (Estonian Electronic ID)
377
+ # - Manufacturer contains "SK" (SK ID Solutions, the issuer)
378
+ # - Label contains "PIN1" or "PIN2" (PIN slot identifiers)
379
+ # - Label contains Estonian text like "Isikutuvastus" (identification)
380
+ #
381
+ # @param slots [Array<PKCS11::Slot>] Array of PKCS#11 slots to examine
382
+ # @return [Array<PKCS11::Slot>] Slots containing Estonian ID cards
383
+ def find_esteid_slots(slots)
384
+ slots.select do |slot|
385
+ # Get token information from the slot
386
+ token_info = slot.token_info
387
+ label = token_info.label.strip
388
+ manufacturer = token_info.manufacturerID.strip
389
+
390
+ # Check for Estonian ID card identifying markers
391
+ label.include?("ESTEID") ||
392
+ manufacturer.include?("SK") ||
393
+ label.match?(/PIN[12]/) ||
394
+ label.include?("Isikutuvastus")
395
+ rescue StandardError
396
+ # If we can't read token info from this slot, skip it
397
+ false
398
+ end
399
+ end
400
+
401
+ # Find authentication certificate among all certificates stored on the card.
402
+ #
403
+ # Estonian ID cards contain multiple certificates for different purposes:
404
+ # - Authentication certificate: For identity verification (uses PIN1)
405
+ # - Signing certificate: For digital signatures (uses PIN2)
406
+ #
407
+ # Authentication certificates are identified by their key usage extension:
408
+ # - Must have "Digital Signature" usage
409
+ # - Must NOT have "Non Repudiation" usage (that's for signing certificates)
410
+ #
411
+ # @return [OpenSSL::X509::Certificate, nil] Authentication certificate or nil
412
+ def find_auth_certificate
413
+ # Find all certificate objects stored on the card
414
+ objects = @session.find_objects(PKCS11::CKA_CLASS => PKCS11::CKO_CERTIFICATE)
415
+
416
+ objects.each do |obj|
417
+ # Get the raw certificate data (DER format)
418
+ cert_der = obj[PKCS11::CKA_VALUE]
419
+ next unless cert_der
420
+
421
+ # Parse the X.509 certificate
422
+ cert = OpenSSL::X509::Certificate.new(cert_der)
423
+
424
+ # Check the key usage extension to determine certificate purpose
425
+ key_usage = cert.extensions.find { |ext| ext.oid == "keyUsage" }
426
+ next unless key_usage
427
+
428
+ usage = key_usage.value
429
+
430
+ # Authentication certificates have Digital Signature but not Non Repudiation
431
+ return cert if usage.include?("Digital Signature") && !usage.include?("Non Repudiation")
432
+ rescue StandardError
433
+ # Skip certificates we can't parse
434
+ next
435
+ end
436
+
437
+ nil
438
+ end
439
+
440
+ # Extract personal code from certificate serial number field.
441
+ #
442
+ # Estonian certificates store the personal identification code in the
443
+ # serialNumber field with a "PNOEE-" prefix (Personal Number Of EstoniE).
444
+ # This method strips the prefix to get the clean 11-digit personal code.
445
+ #
446
+ # @param serial_number [String, nil] Serial number from certificate
447
+ # @return [String, nil] Clean personal code or nil if not found
448
+ def extract_personal_code(serial_number)
449
+ return nil unless serial_number
450
+
451
+ # Remove the PNOEE- prefix if present, otherwise return as-is
452
+ serial_number.start_with?("PNOEE-") ? serial_number[6..] : serial_number
453
+ end
454
+ end
455
+ end