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)!; } }