ai_root_shield 0.3.0 → 0.5.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,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require "net/http"
5
+ require "ipaddr"
6
+
7
+ module AiRootShield
8
+ # Advanced proxy detection for VPN, Tor, custom DNS, and MITM appliances
9
+ class AdvancedProxyDetector
10
+ # Known Tor exit node IP ranges and identifiers
11
+ TOR_INDICATORS = {
12
+ dns_names: [
13
+ "tor-exit", "torservers", "artikel5ev", "privacyfoundation",
14
+ "torproject", "exitnode", "tor-relay"
15
+ ],
16
+ asn_patterns: [
17
+ /tor/i, /privacy/i, /anonymous/i, /vpn/i
18
+ ]
19
+ }.freeze
20
+
21
+ # Common VPN provider indicators
22
+ VPN_INDICATORS = {
23
+ dns_patterns: [
24
+ /vpn/i, /proxy/i, /tunnel/i, /shield/i, /secure/i,
25
+ /private/i, /anonymous/i, /hide/i, /mask/i
26
+ ],
27
+ asn_patterns: [
28
+ /vpn/i, /virtual private/i, /proxy/i, /datacenter/i,
29
+ /hosting/i, /cloud/i, /server/i
30
+ ],
31
+ known_providers: [
32
+ "nordvpn", "expressvpn", "surfshark", "cyberghost", "pia",
33
+ "mullvad", "protonvpn", "windscribe", "tunnelbear", "hotspotshield"
34
+ ]
35
+ }.freeze
36
+
37
+ # MITM appliance indicators
38
+ MITM_INDICATORS = {
39
+ certificate_issuers: [
40
+ /blue coat/i, /websense/i, /forcepoint/i, /zscaler/i,
41
+ /checkpoint/i, /palo alto/i, /fortinet/i, /sophos/i,
42
+ /mcafee/i, /symantec/i, /broadcom/i
43
+ ],
44
+ proxy_headers: [
45
+ "x-bluecoat-via", "x-forwarded-for", "x-real-ip",
46
+ "x-proxy-id", "x-cache", "via", "x-forwarded-proto"
47
+ ]
48
+ }.freeze
49
+
50
+ # Custom DNS server indicators
51
+ CUSTOM_DNS_INDICATORS = {
52
+ public_dns: [
53
+ "8.8.8.8", "8.8.4.4", # Google
54
+ "1.1.1.1", "1.0.0.1", # Cloudflare
55
+ "208.67.222.222", "208.67.220.220", # OpenDNS
56
+ "9.9.9.9", "149.112.112.112" # Quad9
57
+ ],
58
+ privacy_dns: [
59
+ "94.140.14.14", "94.140.15.15", # AdGuard
60
+ "76.76.19.19", "76.223.100.101", # Alternate DNS
61
+ "185.228.168.9", "185.228.169.9" # CleanBrowsing
62
+ ]
63
+ }.freeze
64
+
65
+ def initialize(config = {})
66
+ @config = {
67
+ enable_tor_detection: true,
68
+ enable_vpn_detection: true,
69
+ enable_mitm_detection: true,
70
+ enable_dns_detection: true,
71
+ timeout: 5,
72
+ max_retries: 2,
73
+ use_external_apis: false
74
+ }.merge(config)
75
+
76
+ @detection_cache = {}
77
+ @last_detection_time = {}
78
+ end
79
+
80
+ # Perform comprehensive proxy detection
81
+ # @param target_ip [String] IP address to analyze
82
+ # @param additional_data [Hash] Additional network data
83
+ # @return [Hash] Detection results
84
+ def detect_proxy(target_ip, additional_data = {})
85
+ return cached_result(target_ip) if cached_and_fresh?(target_ip)
86
+
87
+ results = {
88
+ ip_address: target_ip,
89
+ timestamp: Time.now.to_f,
90
+ proxy_detected: false,
91
+ proxy_types: [],
92
+ confidence_score: 0.0,
93
+ indicators: [],
94
+ details: {}
95
+ }
96
+
97
+ # Perform different detection methods
98
+ results = detect_tor_exit_node(target_ip, results) if @config[:enable_tor_detection]
99
+ results = detect_vpn_service(target_ip, results) if @config[:enable_vpn_detection]
100
+ results = detect_mitm_appliance(target_ip, additional_data, results) if @config[:enable_mitm_detection]
101
+ results = detect_custom_dns(additional_data, results) if @config[:enable_dns_detection]
102
+
103
+ # Calculate overall confidence
104
+ results[:confidence_score] = calculate_confidence_score(results)
105
+ results[:proxy_detected] = results[:confidence_score] > 0.5
106
+
107
+ # Cache results
108
+ cache_result(target_ip, results)
109
+
110
+ results
111
+ end
112
+
113
+ # Detect Tor exit nodes
114
+ # @param ip [String] IP address to check
115
+ # @param results [Hash] Current results hash
116
+ # @return [Hash] Updated results
117
+ def detect_tor_exit_node(ip, results)
118
+ tor_indicators = []
119
+
120
+ # Check reverse DNS for Tor indicators
121
+ begin
122
+ hostname = Resolv.getname(ip)
123
+ TOR_INDICATORS[:dns_names].each do |indicator|
124
+ if hostname.downcase.include?(indicator)
125
+ tor_indicators << "tor_dns_pattern: #{indicator}"
126
+ end
127
+ end
128
+ rescue Resolv::ResolvError
129
+ # No reverse DNS available
130
+ end
131
+
132
+ # Check if IP is in known Tor exit node lists (if external APIs enabled)
133
+ if @config[:use_external_apis]
134
+ tor_indicators.concat(check_tor_exit_lists(ip))
135
+ end
136
+
137
+ # Check for Tor-specific network characteristics
138
+ tor_indicators.concat(analyze_tor_network_characteristics(ip))
139
+
140
+ unless tor_indicators.empty?
141
+ results[:proxy_types] << "tor_exit_node"
142
+ results[:indicators].concat(tor_indicators)
143
+ results[:details][:tor] = {
144
+ detected: true,
145
+ indicators: tor_indicators,
146
+ risk_level: "high"
147
+ }
148
+ end
149
+
150
+ results
151
+ end
152
+
153
+ # Detect VPN services
154
+ # @param ip [String] IP address to check
155
+ # @param results [Hash] Current results hash
156
+ # @return [Hash] Updated results
157
+ def detect_vpn_service(ip, results)
158
+ vpn_indicators = []
159
+
160
+ # Check reverse DNS for VPN patterns
161
+ begin
162
+ hostname = Resolv.getname(ip)
163
+ VPN_INDICATORS[:dns_patterns].each do |pattern|
164
+ if hostname.match?(pattern)
165
+ vpn_indicators << "vpn_dns_pattern: #{pattern}"
166
+ end
167
+ end
168
+
169
+ # Check for known VPN providers
170
+ VPN_INDICATORS[:known_providers].each do |provider|
171
+ if hostname.downcase.include?(provider)
172
+ vpn_indicators << "known_vpn_provider: #{provider}"
173
+ end
174
+ end
175
+ rescue Resolv::ResolvError
176
+ # No reverse DNS available
177
+ end
178
+
179
+ # Check ASN information for VPN characteristics
180
+ vpn_indicators.concat(analyze_asn_for_vpn(ip))
181
+
182
+ # Check for datacenter/hosting characteristics
183
+ vpn_indicators.concat(analyze_hosting_characteristics(ip))
184
+
185
+ unless vpn_indicators.empty?
186
+ results[:proxy_types] << "vpn_service"
187
+ results[:indicators].concat(vpn_indicators)
188
+ results[:details][:vpn] = {
189
+ detected: true,
190
+ indicators: vpn_indicators,
191
+ risk_level: "medium"
192
+ }
193
+ end
194
+
195
+ results
196
+ end
197
+
198
+ # Detect MITM appliances
199
+ # @param ip [String] IP address to check
200
+ # @param additional_data [Hash] Additional network data
201
+ # @param results [Hash] Current results hash
202
+ # @return [Hash] Updated results
203
+ def detect_mitm_appliance(ip, additional_data, results)
204
+ mitm_indicators = []
205
+
206
+ # Check for MITM certificate issuers
207
+ if additional_data[:certificates]
208
+ additional_data[:certificates].each do |cert_info|
209
+ issuer = cert_info[:issuer] || ""
210
+ MITM_INDICATORS[:certificate_issuers].each do |pattern|
211
+ if issuer.match?(pattern)
212
+ mitm_indicators << "mitm_certificate_issuer: #{pattern}"
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ # Check for proxy headers
219
+ if additional_data[:http_headers]
220
+ MITM_INDICATORS[:proxy_headers].each do |header|
221
+ if additional_data[:http_headers].key?(header.downcase)
222
+ mitm_indicators << "proxy_header_detected: #{header}"
223
+ end
224
+ end
225
+ end
226
+
227
+ # Check for transparent proxy characteristics
228
+ mitm_indicators.concat(detect_transparent_proxy(ip, additional_data))
229
+
230
+ unless mitm_indicators.empty?
231
+ results[:proxy_types] << "mitm_appliance"
232
+ results[:indicators].concat(mitm_indicators)
233
+ results[:details][:mitm] = {
234
+ detected: true,
235
+ indicators: mitm_indicators,
236
+ risk_level: "high"
237
+ }
238
+ end
239
+
240
+ results
241
+ end
242
+
243
+ # Detect custom DNS configuration
244
+ # @param additional_data [Hash] Additional network data
245
+ # @param results [Hash] Current results hash
246
+ # @return [Hash] Updated results
247
+ def detect_custom_dns(additional_data, results)
248
+ dns_indicators = []
249
+
250
+ # Check configured DNS servers
251
+ if additional_data[:dns_servers]
252
+ dns_servers = Array(additional_data[:dns_servers])
253
+
254
+ dns_servers.each do |dns_server|
255
+ # Check for public DNS services
256
+ if CUSTOM_DNS_INDICATORS[:public_dns].include?(dns_server)
257
+ dns_indicators << "public_dns_detected: #{dns_server}"
258
+ elsif CUSTOM_DNS_INDICATORS[:privacy_dns].include?(dns_server)
259
+ dns_indicators << "privacy_dns_detected: #{dns_server}"
260
+ elsif !is_isp_dns?(dns_server)
261
+ dns_indicators << "custom_dns_detected: #{dns_server}"
262
+ end
263
+ end
264
+ end
265
+
266
+ # Check for DNS over HTTPS/TLS usage
267
+ if additional_data[:doh_enabled] || additional_data[:dot_enabled]
268
+ dns_indicators << "encrypted_dns_detected"
269
+ end
270
+
271
+ unless dns_indicators.empty?
272
+ results[:proxy_types] << "custom_dns"
273
+ results[:indicators].concat(dns_indicators)
274
+ results[:details][:dns] = {
275
+ detected: true,
276
+ indicators: dns_indicators,
277
+ risk_level: "low"
278
+ }
279
+ end
280
+
281
+ results
282
+ end
283
+
284
+ # Get detection statistics
285
+ # @return [Hash] Detection statistics
286
+ def detection_statistics
287
+ {
288
+ total_detections: @detection_cache.size,
289
+ cache_hits: @detection_cache.values.count { |v| v[:cached] },
290
+ detection_types: @detection_cache.values.flat_map { |v| v[:proxy_types] }.tally,
291
+ average_confidence: calculate_average_confidence,
292
+ last_detection: @last_detection_time.values.max
293
+ }
294
+ end
295
+
296
+ # Clear detection cache
297
+ def clear_cache
298
+ @detection_cache.clear
299
+ @last_detection_time.clear
300
+ end
301
+
302
+ private
303
+
304
+ def cached_and_fresh?(ip, ttl = 300)
305
+ return false unless @detection_cache[ip]
306
+ return false unless @last_detection_time[ip]
307
+
308
+ Time.now.to_f - @last_detection_time[ip] < ttl
309
+ end
310
+
311
+ def cached_result(ip)
312
+ result = @detection_cache[ip].dup
313
+ result[:cached] = true
314
+ result
315
+ end
316
+
317
+ def cache_result(ip, results)
318
+ @detection_cache[ip] = results
319
+ @last_detection_time[ip] = Time.now.to_f
320
+ end
321
+
322
+ def calculate_confidence_score(results)
323
+ base_score = 0.0
324
+
325
+ # Weight different proxy types
326
+ type_weights = {
327
+ "tor_exit_node" => 0.9,
328
+ "mitm_appliance" => 0.8,
329
+ "vpn_service" => 0.6,
330
+ "custom_dns" => 0.3
331
+ }
332
+
333
+ results[:proxy_types].each do |type|
334
+ base_score += type_weights[type] || 0.5
335
+ end
336
+
337
+ # Factor in number of indicators
338
+ indicator_bonus = [results[:indicators].size * 0.1, 0.3].min
339
+
340
+ # Cap at 1.0
341
+ [base_score + indicator_bonus, 1.0].min
342
+ end
343
+
344
+ def check_tor_exit_lists(ip)
345
+ # Placeholder for external Tor exit node list checking
346
+ # In production, this would query Tor directory authorities
347
+ []
348
+ end
349
+
350
+ def analyze_tor_network_characteristics(ip)
351
+ indicators = []
352
+
353
+ # Check for common Tor exit node ports
354
+ common_tor_ports = [80, 443, 993, 995]
355
+ # This would require actual port scanning in production
356
+
357
+ indicators
358
+ end
359
+
360
+ def analyze_asn_for_vpn(ip)
361
+ indicators = []
362
+
363
+ # This would require ASN lookup in production
364
+ # For now, return empty array
365
+
366
+ indicators
367
+ end
368
+
369
+ def analyze_hosting_characteristics(ip)
370
+ indicators = []
371
+
372
+ # Check if IP is in known datacenter ranges
373
+ # This would require GeoIP database in production
374
+
375
+ indicators
376
+ end
377
+
378
+ def detect_transparent_proxy(ip, additional_data)
379
+ indicators = []
380
+
381
+ # Check for transparent proxy indicators
382
+ if additional_data[:tcp_timestamp_anomaly]
383
+ indicators << "tcp_timestamp_anomaly"
384
+ end
385
+
386
+ if additional_data[:ttl_anomaly]
387
+ indicators << "ttl_hop_anomaly"
388
+ end
389
+
390
+ indicators
391
+ end
392
+
393
+ def is_isp_dns?(dns_server)
394
+ # Check if DNS server belongs to common ISP ranges
395
+ # This would require ISP database lookup in production
396
+ false
397
+ end
398
+
399
+ def calculate_average_confidence
400
+ return 0.0 if @detection_cache.empty?
401
+
402
+ total_confidence = @detection_cache.values.sum { |v| v[:confidence_score] }
403
+ total_confidence / @detection_cache.size
404
+ end
405
+ end
406
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module AiRootShield
8
+ # Certificate pinning helper for TLS public key pinning integration
9
+ class CertificatePinningHelper
10
+ # Supported hash algorithms for pinning
11
+ SUPPORTED_ALGORITHMS = %w[sha256 sha1].freeze
12
+
13
+ # Common certificate authorities and their pins
14
+ COMMON_CA_PINS = {
15
+ "letsencrypt" => [
16
+ "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", # ISRG Root X1
17
+ "sha256/sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=" # ISRG Root X2
18
+ ],
19
+ "digicert" => [
20
+ "sha256/WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", # DigiCert Global Root G2
21
+ "sha256/RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho=" # DigiCert Global Root CA
22
+ ],
23
+ "google" => [
24
+ "sha256/KwccWaCgrnaw6tsrrSO61FgLacNgG2MMLq8GE6+oP5I=", # GTS Root R1
25
+ "sha256/FEzVOUp4dF3gI0ZVPRJhFbSD608T5Wx5Bp0+jBw/gQo=" # GTS Root R2
26
+ ]
27
+ }.freeze
28
+
29
+ def initialize(config = {})
30
+ @config = {
31
+ algorithm: "sha256",
32
+ backup_pins: [],
33
+ pin_validation_enabled: true,
34
+ allow_backup_pins: true,
35
+ strict_mode: false
36
+ }.merge(config)
37
+
38
+ @pinned_hosts = {}
39
+ @validation_cache = {}
40
+ end
41
+
42
+ # Add certificate pin for a host
43
+ # @param host [String] Hostname to pin
44
+ # @param pins [Array<String>] Array of certificate pins
45
+ # @param options [Hash] Additional options
46
+ def add_pin(host, pins, options = {})
47
+ normalized_host = normalize_host(host)
48
+
49
+ @pinned_hosts[normalized_host] = {
50
+ pins: Array(pins),
51
+ algorithm: options[:algorithm] || @config[:algorithm],
52
+ backup_pins: options[:backup_pins] || [],
53
+ strict_mode: options[:strict_mode] || @config[:strict_mode],
54
+ added_at: Time.now
55
+ }
56
+ end
57
+
58
+ # Validate certificate chain against pinned certificates
59
+ # @param host [String] Hostname being validated
60
+ # @param cert_chain [Array<OpenSSL::X509::Certificate>] Certificate chain
61
+ # @return [Hash] Validation result
62
+ def validate_pin(host, cert_chain)
63
+ normalized_host = normalize_host(host)
64
+ pin_config = @pinned_hosts[normalized_host]
65
+
66
+ return { valid: true, reason: "no_pin_configured" } unless pin_config
67
+
68
+ # Check cache first
69
+ cache_key = generate_cache_key(host, cert_chain)
70
+ return @validation_cache[cache_key] if @validation_cache[cache_key]
71
+
72
+ result = perform_pin_validation(pin_config, cert_chain)
73
+
74
+ # Cache result for performance
75
+ @validation_cache[cache_key] = result
76
+
77
+ result
78
+ end
79
+
80
+ # Extract certificate pin from certificate
81
+ # @param certificate [OpenSSL::X509::Certificate] Certificate to extract pin from
82
+ # @param algorithm [String] Hash algorithm to use
83
+ # @return [String] Certificate pin
84
+ def extract_pin(certificate, algorithm = "sha256")
85
+ public_key_der = certificate.public_key.to_der
86
+
87
+ case algorithm.downcase
88
+ when "sha256"
89
+ digest = OpenSSL::Digest::SHA256.digest(public_key_der)
90
+ "sha256/#{[digest].pack('m0')}"
91
+ when "sha1"
92
+ digest = OpenSSL::Digest::SHA1.digest(public_key_der)
93
+ "sha1/#{[digest].pack('m0')}"
94
+ else
95
+ raise ArgumentError, "Unsupported algorithm: #{algorithm}"
96
+ end
97
+ end
98
+
99
+ # Get certificate chain from URL
100
+ # @param url [String] URL to get certificate chain from
101
+ # @return [Array<OpenSSL::X509::Certificate>] Certificate chain
102
+ def get_certificate_chain(url)
103
+ uri = URI.parse(url)
104
+ return [] unless uri.scheme == "https"
105
+
106
+ cert_chain = []
107
+
108
+ begin
109
+ tcp_socket = TCPSocket.new(uri.host, uri.port || 443)
110
+ ssl_context = OpenSSL::SSL::SSLContext.new
111
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
112
+
113
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
114
+ ssl_socket.hostname = uri.host
115
+ ssl_socket.connect
116
+
117
+ cert_chain = ssl_socket.peer_cert_chain || []
118
+
119
+ ssl_socket.close
120
+ tcp_socket.close
121
+ rescue => e
122
+ # Log error but don't raise to allow graceful handling
123
+ warn "Certificate chain retrieval failed for #{url}: #{e.message}"
124
+ end
125
+
126
+ cert_chain
127
+ end
128
+
129
+ # Generate pins for a URL
130
+ # @param url [String] URL to generate pins for
131
+ # @param algorithm [String] Hash algorithm to use
132
+ # @return [Array<String>] Generated pins
133
+ def generate_pins_for_url(url, algorithm = "sha256")
134
+ cert_chain = get_certificate_chain(url)
135
+ return [] if cert_chain.empty?
136
+
137
+ cert_chain.map { |cert| extract_pin(cert, algorithm) }
138
+ end
139
+
140
+ # Validate current pinning configuration
141
+ # @return [Hash] Validation report
142
+ def validate_configuration
143
+ report = {
144
+ total_pins: @pinned_hosts.size,
145
+ valid_pins: 0,
146
+ invalid_pins: 0,
147
+ issues: []
148
+ }
149
+
150
+ @pinned_hosts.each do |host, config|
151
+ begin
152
+ # Test connectivity and pin validation
153
+ test_url = "https://#{host}"
154
+ cert_chain = get_certificate_chain(test_url)
155
+
156
+ if cert_chain.empty?
157
+ report[:issues] << "Cannot retrieve certificate chain for #{host}"
158
+ report[:invalid_pins] += 1
159
+ else
160
+ validation_result = perform_pin_validation(config, cert_chain)
161
+ if validation_result[:valid]
162
+ report[:valid_pins] += 1
163
+ else
164
+ report[:invalid_pins] += 1
165
+ report[:issues] << "Pin validation failed for #{host}: #{validation_result[:reason]}"
166
+ end
167
+ end
168
+ rescue => e
169
+ report[:invalid_pins] += 1
170
+ report[:issues] << "Error validating #{host}: #{e.message}"
171
+ end
172
+ end
173
+
174
+ report
175
+ end
176
+
177
+ # Get pinning status for all configured hosts
178
+ # @return [Hash] Pinning status
179
+ def pinning_status
180
+ {
181
+ enabled: @config[:pin_validation_enabled],
182
+ total_hosts: @pinned_hosts.size,
183
+ hosts: @pinned_hosts.keys,
184
+ cache_size: @validation_cache.size,
185
+ configuration: @config
186
+ }
187
+ end
188
+
189
+ # Clear validation cache
190
+ def clear_cache
191
+ @validation_cache.clear
192
+ end
193
+
194
+ # Remove pin for host
195
+ # @param host [String] Host to remove pin for
196
+ def remove_pin(host)
197
+ normalized_host = normalize_host(host)
198
+ @pinned_hosts.delete(normalized_host)
199
+
200
+ # Clear related cache entries
201
+ @validation_cache.delete_if { |key, _| key.include?(normalized_host) }
202
+ end
203
+
204
+ # Load pins from common CA configurations
205
+ # @param ca_name [String] CA name (letsencrypt, digicert, google)
206
+ # @param hosts [Array<String>] Hosts to apply CA pins to
207
+ def load_ca_pins(ca_name, hosts)
208
+ ca_pins = COMMON_CA_PINS[ca_name.downcase]
209
+ raise ArgumentError, "Unknown CA: #{ca_name}" unless ca_pins
210
+
211
+ Array(hosts).each do |host|
212
+ add_pin(host, ca_pins, backup_pins: ca_pins)
213
+ end
214
+ end
215
+
216
+ private
217
+
218
+ def normalize_host(host)
219
+ # Remove protocol and path, keep only hostname
220
+ host.gsub(/^https?:\/\//, "").split("/").first.downcase
221
+ end
222
+
223
+ def generate_cache_key(host, cert_chain)
224
+ cert_fingerprints = cert_chain.map { |cert| cert.to_der }.join
225
+ "#{normalize_host(host)}_#{Digest::SHA256.hexdigest(cert_fingerprints)}"
226
+ end
227
+
228
+ def perform_pin_validation(pin_config, cert_chain)
229
+ return { valid: false, reason: "empty_certificate_chain" } if cert_chain.empty?
230
+
231
+ algorithm = pin_config[:algorithm]
232
+ expected_pins = pin_config[:pins] + (pin_config[:backup_pins] || [])
233
+
234
+ # Extract pins from certificate chain
235
+ actual_pins = cert_chain.map { |cert| extract_pin(cert, algorithm) }
236
+
237
+ # Check if any actual pin matches expected pins
238
+ matching_pins = actual_pins & expected_pins
239
+
240
+ if matching_pins.any?
241
+ {
242
+ valid: true,
243
+ reason: "pin_match_found",
244
+ matched_pins: matching_pins,
245
+ algorithm: algorithm
246
+ }
247
+ else
248
+ {
249
+ valid: false,
250
+ reason: "no_pin_match",
251
+ expected_pins: expected_pins,
252
+ actual_pins: actual_pins,
253
+ algorithm: algorithm
254
+ }
255
+ end
256
+ end
257
+ end
258
+ end