Files
dbserver/OpcPksPlatform/Documents/CertificateGenerator.cs

221 lines
9.8 KiB
C#

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<bool> 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<bool> 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<string> 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<IPAddress> 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();
}
}
}