Created
December 28, 2025 11:37
-
-
Save rkttu/b561d1806c575354a1793e752cda4497 to your computer and use it in GitHub Desktop.
PoC of a program to migrate NPKI certificates to the Windows certificate store.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #:package BouncyCastle.Cryptography@2.6.2 | |
| using Org.BouncyCastle.Cryptography; | |
| using Org.BouncyCastle.Asn1; | |
| using Org.BouncyCastle.Crypto; | |
| using Org.BouncyCastle.Crypto.Parameters; | |
| using Org.BouncyCastle.OpenSsl; | |
| using Org.BouncyCastle.Pkcs; | |
| using Org.BouncyCastle.Security; | |
| using Org.BouncyCastle.X509; | |
| using System.Security.Cryptography.X509Certificates; | |
| using System.Text.Json; | |
| var storeName = "NPKI_Store"; | |
| var npkiDirectory = Path.Combine( | |
| Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), | |
| "AppData", "LocalLow", "NPKI"); | |
| NpkiCertHelpers.ImportNpkiNonUserCerts( | |
| storeName, npkiDirectory); | |
| var certs = NpkiCertHelpers.EnumerateNpkiUserCerts( | |
| storeName, npkiDirectory) | |
| var test = certs.Where(x => x.OriginPath.Contains("닷넷데브") && x.OriginPath.Contains("yessign")).First(); | |
| NpkiCertHelpers.ImportUserCertificateWithMetadata(storeName, test, Console.ReadLine()?.ToCharArray() ?? Array.Empty<char>()); | |
| public enum CertType { Undefined, RootCertAuthority, IntermediateCertAuthority, UserCert, }; | |
| public sealed record class NpkiCertInfo(CertType CertType, string OriginPath, DateTime ModifiedUtc, string Thumbprint, string? PrivateKeyPath, DateTime? PrivateKeyModifiedUtc); | |
| public static class NpkiCertHelpers | |
| { | |
| private static readonly Encoding utf8Encoding = new UTF8Encoding(false); | |
| private static readonly JsonWriterOptions writerOptions = new JsonWriterOptions { Indented = false }; | |
| private static NpkiCertInfo GetCertInfo(FileInfo fileInfo) | |
| { | |
| var certType = default(CertType); | |
| var thumbprint = default(string); | |
| using (var cert = X509CertificateLoader.LoadCertificateFromFile(fileInfo.FullName)) | |
| { | |
| thumbprint = cert.Thumbprint; | |
| var extension = cert.Extensions["2.5.29.19"] as X509BasicConstraintsExtension; | |
| if (extension != null && extension.CertificateAuthority) | |
| certType = string.Equals(cert.Subject, cert.Issuer, StringComparison.Ordinal) ? CertType.RootCertAuthority : CertType.IntermediateCertAuthority; | |
| else | |
| certType = CertType.UserCert; | |
| } | |
| string? privateKeyPath = default; | |
| DateTime? privateKeyLastWriteTimeUtc = default; | |
| if (certType == CertType.UserCert) | |
| { | |
| if (fileInfo.Directory != null) | |
| { | |
| var directoryName = fileInfo.Directory.FullName; | |
| var expectedFileName = default(string); | |
| if (fileInfo.Name.Equals("signCert.der", StringComparison.OrdinalIgnoreCase)) | |
| expectedFileName = "signPri.key"; | |
| if (fileInfo.Name.Equals("kmCert.der", StringComparison.OrdinalIgnoreCase)) | |
| expectedFileName = "kmPri.key"; | |
| if (!string.IsNullOrWhiteSpace(expectedFileName)) | |
| { | |
| var privateKeyFileInfo = new FileInfo(Path.Combine(directoryName, expectedFileName)); | |
| if (privateKeyFileInfo.Exists) | |
| { | |
| privateKeyPath = privateKeyFileInfo.FullName; | |
| privateKeyLastWriteTimeUtc = privateKeyFileInfo.LastAccessTimeUtc; | |
| } | |
| } | |
| } | |
| else | |
| Debug.WriteLine("Unexpected condition: parent directory is not available."); | |
| } | |
| return new NpkiCertInfo(certType, fileInfo.FullName, fileInfo.LastWriteTimeUtc, thumbprint, privateKeyPath, privateKeyLastWriteTimeUtc); | |
| } | |
| private static string GetFriendlyName(NpkiCertInfo certInfo) | |
| { | |
| using (var ms = new MemoryStream(255)) | |
| { | |
| using (var writer = new Utf8JsonWriter(ms, writerOptions)) | |
| { | |
| writer.WriteStartObject(); | |
| writer.WriteNumber("c", (int)certInfo.CertType); | |
| writer.WriteNumber("l", certInfo.ModifiedUtc.Ticks); | |
| writer.WriteString("t", certInfo.Thumbprint); | |
| if (certInfo.CertType == CertType.UserCert) | |
| { | |
| if (certInfo.PrivateKeyModifiedUtc.HasValue) | |
| writer.WriteNumber("pl", certInfo.PrivateKeyModifiedUtc.Value.Ticks); | |
| } | |
| writer.WriteEndObject(); | |
| writer.Flush(); | |
| } | |
| return utf8Encoding.GetString(ms.ToArray()); | |
| } | |
| } | |
| public static NpkiCertInfo? ImportNonUserCertificateWithMetadata( | |
| string storeName, FileInfo derFileInfo) | |
| { | |
| if (!derFileInfo.Exists) | |
| throw new FileNotFoundException("파일을 찾을 수 없습니다.", derFileInfo.FullName); | |
| var friendlyName = default(string); | |
| var certInfo = GetCertInfo(derFileInfo); | |
| if (certInfo.CertType == CertType.UserCert) | |
| return null; | |
| friendlyName = GetFriendlyName(certInfo); | |
| using (var cert = X509CertificateLoader.LoadCertificateFromFile(derFileInfo.FullName)) | |
| { | |
| cert.FriendlyName = friendlyName; | |
| using (var store = new X509Store(storeName, StoreLocation.CurrentUser)) | |
| { | |
| store.Open(OpenFlags.ReadWrite); | |
| store.Add(cert); | |
| } | |
| return certInfo; | |
| } | |
| } | |
| public static NpkiCertInfo[] ImportNpkiNonUserCerts(string storeName, string npkiDirectoryPath) | |
| { | |
| var dirInfo = new DirectoryInfo(npkiDirectoryPath); | |
| if (!dirInfo.Exists) | |
| throw new DirectoryNotFoundException("디렉터리를 찾을 수 없습니다."); | |
| var results = new List<NpkiCertInfo>(); | |
| foreach (var eachSubDir in dirInfo.GetDirectories()) | |
| { | |
| foreach (var eachDerFile in eachSubDir.GetFiles("*.der")) | |
| { | |
| var result = ImportNonUserCertificateWithMetadata(storeName, eachDerFile); | |
| if (result != null) | |
| results.Add(result); | |
| } | |
| } | |
| return results.ToArray(); | |
| } | |
| public static NpkiCertInfo[] EnumerateNpkiUserCerts(string storeName, string npkiDirectoryPath) | |
| { | |
| var dirInfo = new DirectoryInfo(npkiDirectoryPath); | |
| if (!dirInfo.Exists) | |
| throw new DirectoryNotFoundException("디렉터리를 찾을 수 없습니다."); | |
| var results = new List<NpkiCertInfo>(); | |
| foreach (var eachDerFile in dirInfo.GetFiles("*.der", SearchOption.AllDirectories)) | |
| { | |
| var result = GetCertInfo(eachDerFile); | |
| if (result.CertType != CertType.UserCert) | |
| continue; | |
| results.Add(result); | |
| } | |
| return results.ToArray(); | |
| } | |
| public static NpkiCertInfo ImportUserCertificateWithMetadata( | |
| string storeName, NpkiCertInfo certInfo, char[] certPasword) | |
| { | |
| if (string.IsNullOrWhiteSpace(certInfo.OriginPath) || | |
| !File.Exists(certInfo.OriginPath)) | |
| throw new ArgumentNullException(nameof(certInfo), "Cert file is required."); | |
| if (string.IsNullOrWhiteSpace(certInfo.PrivateKeyPath) || | |
| !File.Exists(certInfo.PrivateKeyPath)) | |
| throw new ArgumentNullException(nameof(certInfo), "Private key file is required."); | |
| var friendlyName = GetFriendlyName(certInfo); | |
| var data = PfxConverter.CreatePfx( | |
| File.ReadAllBytes(certInfo.OriginPath), | |
| File.ReadAllBytes(certInfo.PrivateKeyPath), | |
| certPasword, () => certPasword, friendlyName); | |
| using (var cert = X509CertificateLoader.LoadPkcs12(data, certPasword)) | |
| { | |
| cert.FriendlyName = friendlyName; | |
| using (var store = new X509Store(storeName, StoreLocation.CurrentUser)) | |
| { | |
| store.Open(OpenFlags.ReadWrite); | |
| store.Add(cert); | |
| } | |
| return certInfo; | |
| } | |
| } | |
| } | |
| public static class PfxConverter | |
| { | |
| public static byte[] CreatePfx( | |
| byte[] certData, // signCert.der (or PEM) | |
| byte[] keyData, // signPri.key (PEM/DER, Plan Text/Encryption) | |
| char[] pfxPassword, // PFX Pasword | |
| Func<char[]>? keyPasswordProvider = null, | |
| string? friendlyName = null) | |
| { | |
| // Load leaf cert | |
| var leaf = LoadSingleCert(certData); | |
| // Load private key | |
| var privateKey = LoadPrivateKeyAuto(keyData, keyPasswordProvider); | |
| // Combine PKCS#12 store (Remove chain, adopt leaf cert only) | |
| var store = new Pkcs12StoreBuilder().Build(); | |
| string alias = friendlyName ?? TryGetCN(leaf) ?? "MyCert"; | |
| var leafEntry = new X509CertificateEntry(leaf); | |
| store.SetKeyEntry( | |
| alias, | |
| new AsymmetricKeyEntry(privateKey), | |
| new[] { leafEntry, }); // Use leaf entry only | |
| using var memStream = new MemoryStream(); | |
| store.Save(memStream, pfxPassword, new SecureRandom()); | |
| return memStream.ToArray(); | |
| } | |
| private static Org.BouncyCastle.X509.X509Certificate LoadSingleCert(byte[] certData) | |
| { | |
| var text = Encoding.ASCII.GetString(certData, 0, certData.Length); | |
| if (text != null && text.Contains("-----BEGIN CERTIFICATE-----")) | |
| { | |
| // PEM: 첫 장만 리프라고 가정 | |
| using var sr = new StringReader(text); | |
| var pem = new PemReader(sr); | |
| var obj = pem.ReadObject(); | |
| if (obj is Org.BouncyCastle.X509.X509Certificate cert) return cert; | |
| // PEM 번들일 경우 첫 장 반환 | |
| return LoadAllCerts(certData).First(); | |
| } | |
| // DER | |
| var parser = new X509CertificateParser(); | |
| return parser.ReadCertificate(certData); | |
| } | |
| private static IEnumerable<Org.BouncyCastle.X509.X509Certificate> LoadAllCerts(byte[] certData) | |
| { | |
| var text = Encoding.ASCII.GetString(certData, 0, certData.Length); | |
| if (text != null && text.Contains("-----BEGIN CERTIFICATE-----")) | |
| { | |
| using var sr = new StringReader(text); | |
| var pem = new PemReader(sr); | |
| object? o; | |
| while ((o = pem.ReadObject()) != null) | |
| if (o is Org.BouncyCastle.X509.X509Certificate c) yield return c; | |
| yield break; | |
| } | |
| // 단일 DER 가정 | |
| yield return new X509CertificateParser().ReadCertificate(certData); | |
| } | |
| private static AsymmetricKeyParameter LoadPrivateKeyAuto( | |
| byte[] keyData, | |
| Func<char[]>? keyPasswordProvider) | |
| { | |
| var text = Encoding.ASCII.GetString(keyData, 0, keyData.Length); | |
| // ---------- 1) PEM ---------- | |
| if (text != null && text.Contains("-----BEGIN")) | |
| { | |
| IPasswordFinder? finder = null; | |
| if (IsEncryptedPem(text)) | |
| { | |
| if (keyPasswordProvider == null) | |
| throw new InvalidOperationException("암호화된 PEM 개인키입니다. keyPasswordProvider가 필요합니다."); | |
| finder = new PasswordFinderAdapter(keyPasswordProvider); | |
| } | |
| using var sr = new StringReader(text); | |
| var pem = new PemReader(sr, finder); | |
| var obj = pem.ReadObject(); | |
| if (obj is AsymmetricCipherKeyPair kp) return kp.Private; | |
| if (obj is AsymmetricKeyParameter akp && akp.IsPrivate) return akp; | |
| throw new InvalidOperationException("PEM에서 유효한 개인키를 찾지 못했습니다."); | |
| } | |
| // ---------- 2) DER ---------- | |
| var der = keyData; | |
| // 2-0) 구조 식별: Encrypted PKCS#8(= size 2) / Plain PKCS#8(= size 3) / 기타 | |
| Asn1Sequence? seq = null; | |
| try { seq = Asn1Sequence.GetInstance(Asn1Object.FromByteArray(der)); } catch { /* ignore */ } | |
| // 2-a) Encrypted PKCS#8 (size == 2): 반드시 비밀번호 필요 | |
| if (seq != null && seq.Count == 2) | |
| { | |
| if (keyPasswordProvider == null) | |
| throw new InvalidOperationException("암호화된 PKCS#8(EncryptedPrivateKeyInfo) 입니다. 키 비밀번호가 필요합니다."); | |
| var pwd = keyPasswordProvider(); | |
| try | |
| { | |
| return PrivateKeyFactory.DecryptKey(pwd, der); | |
| } | |
| catch (Exception ex) | |
| { | |
| throw new InvalidOperationException("Encrypted PKCS#8 복호화에 실패했습니다. 비밀번호를 확인하세요.", ex); | |
| } | |
| } | |
| // 2-b) Plain PKCS#8 (보통 size == 3) | |
| try | |
| { | |
| return PrivateKeyFactory.CreateKey(der); | |
| } | |
| catch | |
| { | |
| // fallthrough | |
| } | |
| // 2-c) RSA PKCS#1 (최후의 수단) | |
| try | |
| { | |
| if (seq != null) | |
| { | |
| var rsa = Org.BouncyCastle.Asn1.Pkcs.RsaPrivateKeyStructure.GetInstance(seq); | |
| return new RsaPrivateCrtKeyParameters( | |
| rsa.Modulus, rsa.PublicExponent, rsa.PrivateExponent, | |
| rsa.Prime1, rsa.Prime2, rsa.Exponent1, rsa.Exponent2, rsa.Coefficient | |
| ); | |
| } | |
| } | |
| catch | |
| { | |
| // ignore and throw below | |
| } | |
| throw new InvalidOperationException( | |
| "지원되지 않는 DER 개인키 형식이거나 비밀번호가 누락되었습니다. " + | |
| "가능한 형식: PKCS#8(평문/암호화), RSA PKCS#1, PEM" | |
| ); | |
| } | |
| private static string? TryGetCN(Org.BouncyCastle.X509.X509Certificate cert) | |
| { | |
| try | |
| { | |
| var attrs = cert.SubjectDN; | |
| var oids = attrs.GetOidList(); | |
| var vals = attrs.GetValueList(); | |
| for (int i = 0; i < oids.Count; i++) | |
| if (oids[i].Equals(Org.BouncyCastle.Asn1.X509.X509ObjectIdentifiers.CommonName)) | |
| return vals[i]?.ToString(); | |
| return cert.SubjectDN?.ToString(); | |
| } | |
| catch { return null; } | |
| } | |
| private static bool IsEncryptedPem(string pemText) | |
| { | |
| // PKCS#8 (BEGIN ENCRYPTED PRIVATE KEY) or | |
| // OpenSSL 전통 포맷의 암호화된 키(Proc-Type: 4,ENCRYPTED / DEK-Info) | |
| return pemText.Contains("BEGIN ENCRYPTED PRIVATE KEY") | |
| || pemText.Contains("Proc-Type: 4,ENCRYPTED") | |
| || pemText.Contains("DEK-Info:"); | |
| } | |
| private sealed class PasswordFinderAdapter : IPasswordFinder | |
| { | |
| private readonly Func<char[]> _provider; | |
| public PasswordFinderAdapter(Func<char[]> provider) => _provider = provider; | |
| public char[] GetPassword() => _provider(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment