221 lines
9.8 KiB
C#
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();
|
|
}
|
|
}
|
|
} |