using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
///
/// OPC UA 클라이언트용 X.509 인증서를 생성하고 PFX로 저장합니다.
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더 구조, X509KeyStorageFlags)를 유지합니다.
///
public class CertService
{
private readonly ILogger _logger;
private readonly string _pkiRoot;
public CertService(ILogger logger, IWebHostEnvironment env)
{
_logger = logger;
_pkiRoot = Path.Combine(env.ContentRootPath, "pki");
}
private void EnsurePkiDirectories()
{
Directory.CreateDirectory(Path.Combine(_pkiRoot, "own", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "trusted", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "issuers", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "rejected", "certs"));
}
public Task GenerateAsync(CertCreateRequest req)
{
try
{
EnsurePkiDirectories();
string appUri = $"urn:{req.ClientHostName}:{req.ApplicationName}";
// ── 1. RSA 키 생성 ────────────────────────────────────────
using var rsa = RSA.Create(2048);
// ── 2. Distinguished Name ──────────────────────────────────
var dn = new X500DistinguishedName(
$"CN={req.ApplicationName}, O={req.ClientHostName}, C=KR");
// ── 3. Certificate Request ─────────────────────────────────
var certReq = new CertificateRequest(
dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// ── 4. Extensions ──────────────────────────────────────────
certReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
certReq.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature |
X509KeyUsageFlags.NonRepudiation |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DataEncipherment,
critical: true));
certReq.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.2"), // clientAuth
new Oid("1.3.6.1.5.5.7.3.1") // serverAuth
}, false));
certReq.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(certReq.PublicKey, false));
// ── 5. Subject Alternative Names ──────────────────────────
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddUri(new Uri(appUri));
if (!string.IsNullOrWhiteSpace(req.ServerIp) &&
System.Net.IPAddress.TryParse(req.ServerIp, out var ip))
sanBuilder.AddIpAddress(ip);
if (!string.IsNullOrWhiteSpace(req.ServerHostName))
sanBuilder.AddDnsName(req.ServerHostName);
sanBuilder.AddDnsName(req.ClientHostName);
sanBuilder.AddDnsName("localhost");
certReq.CertificateExtensions.Add(sanBuilder.Build());
// ── 6. 자체 서명 인증서 생성 ──────────────────────────────
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
var notAfter = notBefore.AddDays(req.ValidDays);
// FIX: CreateSelfSigned 는 이미 RSA 키가 연결된 X509Certificate2 를 반환함
// → CopyWithPrivateKey() 를 추가로 호출하면
// "The certificate already has an associated private key" 에러 발생
// → CreateSelfSigned 결과를 직접 Export(Pfx) 하면 개인키 포함됨
using var certWithKey = certReq.CreateSelfSigned(notBefore, notAfter);
// ── 7. PFX 내보내기 ───────────────────────────────────────
string pfxPassword = string.IsNullOrEmpty(req.PfxPassword) ? "" : req.PfxPassword;
// .NET 9 이상: X509CertificateLoader 권장이지만 net8 에서는 Export 사용
byte[] pfxBytes = certWithKey.Export(X509ContentType.Pfx, pfxPassword);
string pfxFileName = $"{req.ApplicationName}.pfx";
string pfxPath = Path.Combine(_pkiRoot, "own", "certs", pfxFileName);
File.WriteAllBytes(pfxPath, pfxBytes);
// ── 8. Trusted 폴더에 DER(공개키) 복사 ───────────────────
byte[] derBytes = certWithKey.Export(X509ContentType.Cert);
string derPath = Path.Combine(_pkiRoot, "trusted", "certs",
$"{req.ApplicationName}[{certWithKey.Thumbprint}].der");
File.WriteAllBytes(derPath, derBytes);
_logger.LogInformation(
"인증서 생성 완료 → {PfxPath} | Thumbprint: {Thumbprint}",
pfxPath, certWithKey.Thumbprint);
return Task.FromResult(new CertCreateResult
{
Success = true,
Message = $"인증서 생성 완료: {pfxFileName}",
PfxPath = pfxPath,
ApplicationUri = appUri,
Thumbprint = certWithKey.Thumbprint ?? string.Empty,
SerialNumber = certWithKey.SerialNumber ?? string.Empty,
NotBefore = notBefore.ToString("yyyy-MM-dd HH:mm:ss UTC"),
NotAfter = notAfter.ToString("yyyy-MM-dd HH:mm:ss UTC"),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "인증서 생성 실패");
return Task.FromResult(new CertCreateResult
{
Success = false,
Message = $"인증서 생성 실패: {ex.Message}"
});
}
}
///
/// 기존 PFX를 로드합니다 (원본 코드와 동일한 플래그).
///
public X509Certificate2 LoadPfx(string pfxPath, string pfxPassword)
{
return new X509Certificate2(
pfxPath,
pfxPassword,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
}
public IEnumerable ListCertificates()
{
string dir = Path.Combine(_pkiRoot, "own", "certs");
if (!Directory.Exists(dir)) return [];
return Directory.GetFiles(dir, "*.pfx").Select(Path.GetFileName)!;
}
}