159 lines
7.1 KiB
C#
159 lines
7.1 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using OpcUaManager.Models;
|
|
|
|
namespace OpcUaManager.Services;
|
|
|
|
/// <summary>
|
|
/// OPC UA 클라이언트용 X.509 인증서를 생성하고 PFX로 저장합니다.
|
|
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더 구조, X509KeyStorageFlags)를 유지합니다.
|
|
/// </summary>
|
|
public class CertService
|
|
{
|
|
private readonly ILogger<CertService> _logger;
|
|
private readonly string _pkiRoot;
|
|
|
|
public CertService(ILogger<CertService> 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<CertCreateResult> 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}"
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기존 PFX를 로드합니다 (원본 코드와 동일한 플래그).
|
|
/// </summary>
|
|
public X509Certificate2 LoadPfx(string pfxPath, string pfxPassword)
|
|
{
|
|
return new X509Certificate2(
|
|
pfxPath,
|
|
pfxPassword,
|
|
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
|
|
}
|
|
|
|
public IEnumerable<string> ListCertificates()
|
|
{
|
|
string dir = Path.Combine(_pkiRoot, "own", "certs");
|
|
if (!Directory.Exists(dir)) return [];
|
|
return Directory.GetFiles(dir, "*.pfx").Select(Path.GetFileName)!;
|
|
}
|
|
}
|