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.
- checksums.yaml +7 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +67 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +201 -0
- data/Makefile +98 -0
- data/README.md +752 -0
- data/Rakefile +13 -0
- data/lib/ee_id_verification/certificate_reader.rb +455 -0
- data/lib/ee_id_verification/models.rb +273 -0
- data/lib/ee_id_verification/version.rb +5 -0
- data/lib/ee_id_verification.rb +118 -0
- data/script/test_id_card.rb +255 -0
- metadata +73 -0
data/Rakefile
ADDED
@@ -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
|