using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Net; using System.Net.NetworkInformation; using OpcPks.Core.Models; using System.Diagnostics; using System.Text; namespace OpcPks.Core.Services { public class CertificateGenerator { private readonly string _baseDataPath; private readonly OpcSessionManager _sessionManager; public CertificateGenerator(string baseDataPath, OpcSessionManager sessionManager) { _baseDataPath = baseDataPath; _sessionManager = sessionManager; } public async Task CreateHoneywellCertificateAsync(CertRequestModel model) { try { string appName = "OpcPksClient"; // 초기 성공 모델과 동일한 URI 패턴 유지 string applicationUri = $"urn:{Environment.MachineName}:OpcPksClient"; string derPath = Path.Combine(_baseDataPath, "own", "certs", $"{appName}.der"); string pfxPath = Path.Combine(_baseDataPath, "own", "private", $"{appName}.pfx"); Directory.CreateDirectory(Path.GetDirectoryName(derPath)!); Directory.CreateDirectory(Path.GetDirectoryName(pfxPath)!); // 1. RSA 키 생성 (2048-bit) using var rsa = RSA.Create(2048); // 2. 인증서 요청 생성 var request = new CertificateRequest( $"CN={appName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); // 3. [Critical] 하니웰이 요구하는 '신분증 지문' 및 '도장' 추가 // A. Subject Key Identifier (이게 없으면 하니웰 엔진이 인증서를 무시합니다) request.CertificateExtensions.Add( new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); // B. Subject Alternative Name (SAN) var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddUri(new Uri(applicationUri)); sanBuilder.AddDnsName(Environment.MachineName); sanBuilder.AddDnsName("localhost"); foreach (var ip in GetLocalIpAddresses()) sanBuilder.AddIpAddress(ip); request.CertificateExtensions.Add(sanBuilder.Build()); // C. Key Usage (하니웰 보안 4종 세트) request.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.NonRepudiation | X509KeyUsageFlags.DataEncipherment, true)); // D. Enhanced Key Usage (EKU) request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( new OidCollection { new Oid("1.3.6.1.5.5.7.3.1"), // Server Auth new Oid("1.3.6.1.5.5.7.3.2") // Client Auth }, false)); // E. Basic Constraints (End Entity 명시) request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); // 4. 인증서 생성 (초기 코드처럼 넉넉하게 10년) using var certificate = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); // 5. 파일 저장 // PFX는 암호 없이 추출 (호환성) await File.WriteAllBytesAsync(pfxPath, certificate.Export(X509ContentType.Pfx)); // DER은 하니웰 서버 전송용 await File.WriteAllBytesAsync(derPath, certificate.Export(X509ContentType.Cert)); Console.WriteLine($"✅ [Step 1] 하니웰 지문(SKI) 포함 인증서 제작 완료: {derPath}"); // 6. 전송 및 검증 string primaryIp = model.PrimaryIpA?.Trim() ?? ""; if (string.IsNullOrEmpty(primaryIp)) throw new Exception("Primary IP 주소가 비어있습니다."); await TransferAndVerifyWithRetryAsync(model, primaryIp, $"{appName}.der", derPath); return true; } catch (Exception ex) { Console.WriteLine($"❌ 작업 최종 실패: {ex.Message}"); throw; } } private async Task TransferAndVerifyWithRetryAsync(CertRequestModel model, string ip, string fileName, string localPath) { string winTrustedDir = @"ProgramData\Honeywell\Experion PKS\Server\data\CertStore\opcuaserver\pki\DefaultApplicationGroup\trusted\certs"; string winRejectedDir = @"ProgramData\Honeywell\Experion PKS\Server\data\CertStore\opcuaserver\pki\DefaultApplicationGroup\rejected\certs"; string auth = $"{model.AdminId}%{model.AdminPassword}"; int maxRetries = 3; bool isAuthorized = false; for (int attempt = 1; attempt <= maxRetries; attempt++) { Console.WriteLine($"\n🔄 [{ip}] {attempt}회차 시퀀스 시작..."); string trustedFile = $"{winTrustedDir}\\{fileName}"; string rejectedFile = $"{winRejectedDir}\\{fileName}"; // 1. 기존 파일 삭제 및 새 인증서 전송 await DeleteFileFromHoneywell(ip, trustedFile, auth); await PutFileToHoneywell(ip, localPath, trustedFile, auth); await Task.Delay(3000); // 2. 접속 시도 (하니웰 반응 유도) isAuthorized = await _sessionManager.SayHelloAsync(ip); if (isAuthorized) { Console.WriteLine($"🎯 [{ip}] 인증 성공!"); return; } Console.WriteLine($"⚠️ [{ip}] 거절됨(0x80830000). Rejected 폴더 확인 중..."); await Task.Delay(2000); // 3. Rejected 유배지로 갔다면 강제 이동 (하니웰의 '불신' 극복) if (await CheckFileExists(ip, rejectedFile, auth)) { Console.WriteLine($"🚩 [{ip}] Rejected 유배 확인! Trusted로 강제 이동합니다."); string moveArgs = $"//{ip}/C$ -U \"{auth}\" -c \"rename \\\"{rejectedFile}\\\" \\\"{trustedFile}\\\"\""; await ExecuteSmbCommandAsync(moveArgs); await Task.Delay(2000); if (await _sessionManager.SayHelloAsync(ip)) return; } } if (!isAuthorized) throw new Exception($"❌ [{ip}] 지문 인식 실패 또는 Extensions 불일치. 수동 Trust 필요."); } private async Task PutFileToHoneywell(string ip, string localPath, string remotePath, string auth) { string args = $"//{ip}/C$ -U \"{auth}\" -c \"put \\\"{localPath}\\\" \\\"{remotePath}\\\"\""; await ExecuteSmbCommandAsync(args); } private async Task DeleteFileFromHoneywell(string ip, string remotePath, string auth) { try { string args = $"//{ip}/C$ -U \"{auth}\" -c \"del \\\"{remotePath}\\\"\""; await ExecuteSmbCommandAsync(args); } catch { } } private async Task CheckFileExists(string ip, string remotePath, string auth) { try { string args = $"//{ip}/C$ -U \"{auth}\" -c \"allinfo \\\"{remotePath}\\\"\""; string result = await ExecuteSmbCommandWithOutputAsync(args); return !result.Contains("NT_STATUS_OBJECT_NAME_NOT_FOUND"); } catch { return false; } } private async Task ExecuteSmbCommandAsync(string args) { using var process = new Process { StartInfo = new ProcessStartInfo { FileName = "smbclient", Arguments = args, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; process.Start(); string error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); if (process.ExitCode != 0 && !error.Contains("NT_STATUS_OBJECT_NAME_NOT_FOUND")) throw new Exception($"SMB 실패: {error.Trim()}"); } private async Task ExecuteSmbCommandWithOutputAsync(string args) { using var process = new Process { StartInfo = new ProcessStartInfo { FileName = "smbclient", Arguments = args, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; process.Start(); string output = await process.StandardOutput.ReadToEndAsync(); string error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); return output + error; } private List GetLocalIpAddresses() { return NetworkInterface.GetAllNetworkInterfaces() .Where(i => i.OperationalStatus == OperationalStatus.Up) .SelectMany(i => i.GetIPProperties().UnicastAddresses) .Where(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) .Select(a => a.Address).ToList(); } } }