Skip to content

Instantly share code, notes, and snippets.

@rkttu
Created December 28, 2025 11:37
Show Gist options
  • Select an option

  • Save rkttu/b561d1806c575354a1793e752cda4497 to your computer and use it in GitHub Desktop.

Select an option

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.
#: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