Request-InfisicalCertificate + PKI lifecycle, MAML help for all 39 cmdlets, chain-store routing fix

Cmdlets added: Request-InfisicalCertificate, Get-InfisicalCertificate, Get-InfisicalCertificates. Request supports BySubscriber/ByCa parameter sets, BouncyCastle CSR generation (RSA/ECDSA/Ed25519), local-key generation, -Install/-InstallChain (chain certs routed to Root vs CertificateAuthority by self-signed status), idempotency reuse with -AllowRenewal/-RenewalThresholdDays, local chain reconstruction with -LocalChainOnly opt-out, Infisical bundle fallback when local stores are incomplete, and private-key protection modes (Exportable/LocalOnly/NonExportable/Ephemeral) via -PrivateKeyProtection plus -PersistKey/-MachineKey/-PrivateKeyPath.

Install-InfisicalCertificate fix: chain certs were previously dumped into CertificateAuthority unconditionally. They are now routed by Subject==Issuer (self-signed -> Root, otherwise -> CertificateAuthority), matching Request-InfisicalCertificate. Routing centralized in InfisicalCertificateRequestHelpers.GetChainCertificateTargetStore and a new InstallChain(IEnumerable<X509Certificate2>,...) overload.

Help: authored Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml covering all 39 cmdlets (synopsis, description, notes, two examples per cmdlet: one-liner + OrderedDictionary splat with preceding Get- resolvers for IDs/slugs).

Build pipeline: build.ps1 stages the help XML into bin/<culture>/ next to the DLL during publish (hard-fails if missing or has zero <command:command> entries). Test-ModuleImports now enumerates every exported cmdlet via Get-Command, cross-checks against expected names, and asserts non-empty synopsis (rejecting auto-generated cmdlet-name fallback), non-empty description, and at least one example with a non-empty <dev:code> block.

Tests: 230/230 passing (up from 190).
This commit is contained in:
GraceSolutions
2026-06-04 14:26:40 -04:00
parent 19615363e3
commit 51bf819c37
28 changed files with 5192 additions and 44 deletions
+99
View File
@@ -6,6 +6,105 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
## 2026.06.04.1825
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1820
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
- `Install-InfisicalCertificate` now routes chain certificates by self-signed status instead of dumping every chain entry into the Intermediate Certification Authorities store. Self-signed roots are installed into `StoreName.Root` (Trusted Root Certification Authorities) and non-self-signed intermediates are installed into `StoreName.CertificateAuthority` (Intermediate Certification Authorities). The leaf continues to use the user-specified `-StoreName`/`-StoreLocation` (default `My`/`CurrentUser`). `Request-InfisicalCertificate` already routed chain certs correctly; the same routing helper is now shared by both cmdlets.
- `InfisicalCertificateRequestHelpers` exposes a new public `GetChainCertificateTargetStore(X509Certificate2)` classifier and a new `InstallChain(IEnumerable<X509Certificate2>, StoreLocation, bool, IInfisicalLogger, string)` overload. The existing `InstallChain(InfisicalSignedCertificate, ...)` overload now delegates to the new collection-based overload, so PKI chain-installation routing is centralized in one place.
## 2026.06.04.1810
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
- Authored MAML help (`Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml`) covering all 39 exported cmdlets. Every entry includes a synopsis, description, notes section, and two examples: a one-liner and an `OrderedDictionary` splat (with `OrdinalIgnoreCase`) that includes preceding `Get-` resolver commands wherever IDs or slugs are required.
- `build.ps1` now stages the cmdlet help XML next to the deployed binary. After the publish step, every culture directory under `Module/PSInfisicalAPI/` (matching `xx` or `xx-XX`) that contains `PSInfisicalAPI.dll-Help.xml` is mirrored into `bin/<culture>/`. The script hard-fails if `bin/en-US/PSInfisicalAPI.dll-Help.xml` is missing or contains zero `<command:command>` entries.
- `Test-ModuleImports` in `build.ps1` now dynamically enumerates exported cmdlets via `Get-Command -Module PSInfisicalAPI -CommandType Cmdlet`, cross-checks the result against an expected list of 39 cmdlet names (including the previously-missing `Copy-InfisicalSecret`), and for each cmdlet asserts that `Get-Help -Full` returns a non-empty synopsis (rejecting PowerShell's auto-generated cmdlet-name fallback), a non-empty description, and that `Get-Help -Examples` returns at least one example node whose `<dev:code>` block is non-empty.
## 2026.06.04.1808
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1658
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
- `Request-InfisicalCertificate` reuse path now falls back to the Infisical certificate-bundle endpoint when the local trust stores do not contain the issuing intermediates or root. The cmdlet builds the local chain first; if the result has no intermediates and no root, it fetches `GetCertificateBundle(serialNumber)` and rebuilds the result with the bundle's chain PEM merged in. A new `-LocalChainOnly` switch opts out of the bundle fetch for strict offline behavior. Bundle-fetch failures are logged at verbose level and the cmdlet returns the local-only result.
- `InfisicalCertificateRequestHelpers.BuildResultFromExistingLocal` adds a second overload that accepts an `InfisicalCertificateBundle`; when supplied, chain certs from the bundle are deduplicated by thumbprint and merged with the locally-resolved chain before classification.
## 2026.06.04.1652
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1651
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1634
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1631
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1622
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
- **PKI contract fixes and cmdlet expansion**:
- `InfisicalPkiClient` no longer auto-injects `connection.ProjectId` into PKI CA list/retrieve calls; only the caller's explicit `-ProjectId` is forwarded so that cert-manager primary routes (which do not accept the query parameter) succeed.
- List/single CA and single certificate response parsing now tolerate raw arrays, wrapper objects (`{certificate: {...}}`, `{certificates: [...]}`), and nested `configuration` blocks. `InfisicalCaMapper` reads CA detail fields from `configuration` first, falling back to top-level.
- `RetrieveCertificate(connection, identifier)` added on `InfisicalPkiClient`.
- **New cmdlets**:
- **`Get-InfisicalCertificate`** — single-record retrieval by `-SerialNumber`/`-Id` (mandatory positional).
- **`Get-InfisicalCertificates`** — listing with light filtering (`-CommonName`, `-FriendlyName`, `-Status`, `-CaId`, `-Limit`, `-Offset`, `-NoAutoPage`). Auto-paginates by default.
- **`Request-InfisicalCertificate`** — generates a keypair locally (private key never leaves the device), submits a PKCS#10 CSR to either `pki-subscribers/{name}/sign-certificate` (`-PkiSubscriberSlug`) or `ca/{caId}/sign-certificate` (`-CertificateAuthorityId`), and returns a single `InfisicalCertificateResult` object with the leaf and chain pre-classified. The result exposes `Leaf : X509Certificate2`, `Intermediates : X509Certificate2[]`, `Root : X509Certificate2` (nullable), `Chain : X509Certificate2[]` (ordered leaf → intermediates → root, deduplicated by thumbprint), plus pass-through `SerialNumber`, `CertificatePem`, `CertificateChainPem`, and `PrivateKeyPem`. Supports `-Subject` (`IDictionary` with `CN`/`C`/`ST`/`L`/`O`/`OU`/`E` keys) merged with individual `-CommonName`/`-Country`/etc. parameters (individual params win), `-DnsName`/`-IpAddress` SANs (auto-populated from local FQDN when omitted). Idempotency: scans the local `X509Store` for an existing certificate matching `CN` and an Infisical-known serial number; returns the existing certificate wrapped in an `InfisicalCertificateResult` whose `Intermediates`/`Root`/`Chain` are populated by walking the local trust stores via `X509Chain` (no network calls, revocation checks disabled), and whose `CertificatePem`/`CertificateChainPem` are reconstructed from the resolved certs. Reuse is short-circuited unless `-Force` or `-AllowRenewal` (with optional `-RenewalThresholdDays`, default 30) requests a new one. Installation: `-Install` adds the leaf to `-StoreName`/`-StoreLocation` (default `My`/`CurrentUser`); `-InstallChain` additionally places intermediates into `CertificateAuthority` and self-signed roots into `Root` for the same `-StoreLocation`. `-KeyStorageFlags` is passed through to `X509Certificate2` import.
- **Multi-algorithm CSR support** on `Request-InfisicalCertificate` via split parameters: `-KeyAlgorithm` (`Rsa`/`Ecdsa`/`Ed25519`, default `Rsa`), `-KeySize` (`2048`/`3072`/`4096`, default `2048`, applies to RSA only), `-Curve` (`P256`/`P384`, default `P256`, applies to ECDSA only). Signature algorithms are picked automatically: SHA256WITHRSA for RSA, SHA256WITHECDSA / SHA384WITHECDSA for ECDSA P-256/P-384, and Ed25519 (pure-EdDSA) for Ed25519. The underlying `InfisicalCsrBuilder.Build(subject, dns, ip, options)` API was updated to take an `InfisicalCsrOptions` object in place of the prior `keySize` int.
- **Sign-certificate endpoint registrations**: `SignCertificateBySubscriber` and `SignCertificateByCa` registered with both `/api/v1/pki/...` and `/api/v1/cert-manager/...` candidate paths and marked `ContainsSecretMaterialInResponse = true`.
## 2026.06.04.1554
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1512
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
## 2026.06.04.1508
- Build produced from commit 19615363e356.
## Unreleased (carried forward)
- **CI — Gitea artifact upload fix**: Replaced `actions/upload-artifact@v4` and `actions/download-artifact@v4` with the Gitea-compatible forks `christopherhx/gitea-upload-artifact@v4` and `christopherhx/gitea-download-artifact@v4` in `.gitea/workflows/publish-psgallery.yml`. The upstream v4 actions abort on Gitea because Gitea is detected as GHES, which the upstream v4 actions do not support (see [go-gitea/gitea#28853](https://github.com/go-gitea/gitea/issues/28853)).
## 2026.06.04.0123
+5 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.06.04.0123'
ModuleVersion = '2026.06.04.1825'
GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
Author = 'Grace Solutions'
CompanyName = 'Grace Solutions'
@@ -41,7 +41,10 @@
'Update-InfisicalTag',
'Remove-InfisicalTag',
'Get-InfisicalCertificateAuthority',
'Get-InfisicalCertificate',
'Get-InfisicalCertificates',
'Search-InfisicalCertificate',
'Request-InfisicalCertificate',
'ConvertTo-InfisicalCertificate',
'Install-InfisicalCertificate',
'Uninstall-InfisicalCertificate',
@@ -57,7 +60,7 @@
LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html'
ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI'
ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.'
CommitHash = '2cbd5c2008f5'
CommitHash = '19615363e356'
}
}
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+74 -7
View File
@@ -129,7 +129,10 @@ function Write-Manifest {
'Update-InfisicalTag',
'Remove-InfisicalTag',
'Get-InfisicalCertificateAuthority',
'Get-InfisicalCertificate',
'Get-InfisicalCertificates',
'Search-InfisicalCertificate',
'Request-InfisicalCertificate',
'ConvertTo-InfisicalCertificate',
'Install-InfisicalCertificate',
'Uninstall-InfisicalCertificate',
@@ -193,15 +196,50 @@ if (`$null -eq `$manifest) {
Import-Module -Name '$($ModuleDirectory.FullName)' -Force
`$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProjects','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironments','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolders','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTags','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalCertificateAuthority','Search-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate')
foreach (`$c in `$cmds) {
if (-not (Get-Command -Name `$c -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) {
throw "Cmdlet not found: `$c"
`$cmds = @(Get-Command -Module PSInfisicalAPI -CommandType Cmdlet)
if (`$cmds.Count -eq 0) {
throw "No cmdlets were exported by the PSInfisicalAPI module."
}
`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProjects','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironments','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolders','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTags','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalCertificateAuthority','Get-InfisicalCertificate','Get-InfisicalCertificates','Search-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate')
foreach (`$expected in `$expectedCmds) {
if (-not (Get-Command -Name `$expected -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) {
throw "Cmdlet not found: `$expected"
}
}
foreach (`$cmd in `$cmds) {
`$name = `$cmd.Name
`$help = Get-Help -Name `$name -Full -ErrorAction SilentlyContinue
if (`$null -eq `$help) {
throw "Get-Help returned nothing for cmdlet: `$name"
}
`$help = Get-Help -Name `$c -ErrorAction SilentlyContinue
if (`$null -eq `$help) {
throw "Get-Help returned nothing for cmdlet: `$c"
`$synopsis = (`$help.Synopsis | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace(`$synopsis) -or `$synopsis.StartsWith(`$name, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Get-Help synopsis is missing or auto-generated for cmdlet: `$name"
}
`$description = (`$help.description | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace(`$description)) {
throw "Get-Help description is empty for cmdlet: `$name"
}
`$examples = Get-Help -Name `$name -Examples -ErrorAction SilentlyContinue
if (`$null -eq `$examples -or `$null -eq `$examples.examples -or `$null -eq `$examples.examples.example) {
throw "Get-Help -Examples returned no examples for cmdlet: `$name"
}
`$exampleNodes = @(`$examples.examples.example)
if (`$exampleNodes.Count -lt 1) {
throw "Get-Help -Examples returned zero examples for cmdlet: `$name"
}
foreach (`$example in `$exampleNodes) {
`$code = (`$example.code | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace(`$code)) {
throw "Example with empty code block found for cmdlet: `$name"
}
}
}
@@ -297,6 +335,35 @@ foreach ($assembly in $desiredAssemblies) {
}
}
Write-Step "Staging cmdlet help XML next to module binary"
$moduleCultureDirs = Get-ChildItem -LiteralPath $ModuleRoot.FullName -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^[a-z]{2}(-[A-Za-z0-9]+)*$' }
foreach ($cultureDir in $moduleCultureDirs) {
$helpXmlSource = [System.IO.FileInfo][System.IO.Path]::Combine($cultureDir.FullName, 'PSInfisicalAPI.dll-Help.xml')
if (-not $helpXmlSource.Exists) { continue }
$binCultureDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ModuleBinDir.FullName, $cultureDir.Name)
Ensure-Directory -Directory $binCultureDir
Copy-Item -LiteralPath $helpXmlSource.FullName -Destination $binCultureDir.FullName -Force
}
$primaryHelpXml = [System.IO.FileInfo][System.IO.Path]::Combine($ModuleBinDir.FullName, 'en-US', 'PSInfisicalAPI.dll-Help.xml')
if (-not $primaryHelpXml.Exists) {
throw "Help XML not found at '$($primaryHelpXml.FullName)'. Ensure Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml exists."
}
try {
[xml]$helpDocument = Get-Content -LiteralPath $primaryHelpXml.FullName -Raw
} catch {
throw "Help XML at '$($primaryHelpXml.FullName)' failed to parse as XML: $_"
}
$helpCommandCount = @($helpDocument.helpItems.command).Count
if ($helpCommandCount -lt 1) {
throw "Help XML at '$($primaryHelpXml.FullName)' contains no <command:command> entries."
}
Write-Step "Help XML contains $helpCommandCount cmdlet entries."
$manifestPath = [System.IO.FileInfo][System.IO.Path]::Combine($ModuleRoot.FullName, 'PSInfisicalAPI.psd1')
Write-Manifest -Path $manifestPath -ModuleVersion $buildVersion -CommitHash $commitHash
@@ -12,6 +12,7 @@ namespace PSInfisicalAPI.Tests
private static readonly Type CertDtoType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateResponseDto", true);
private static readonly Type CaMapperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCaMapper", true);
private static readonly Type CaDtoType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalInternalCaResponseDto", true);
private static readonly Type CaConfigDtoType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalInternalCaConfigurationDto", true);
private static readonly Type BundleDtoType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateBundleResponseDto", true);
private static InfisicalCertificate InvokeCertMap(object dto, string fallbackProjectId)
@@ -90,6 +91,43 @@ namespace PSInfisicalAPI.Tests
Assert.Equal("proj-fallback", mapped.ProjectId);
}
[Fact]
public void CaMap_Prefers_Configuration_Fields_Over_TopLevel()
{
object cfg = Activator.CreateInstance(CaConfigDtoType);
CaConfigDtoType.GetProperty("FriendlyName").SetValue(cfg, "C=US, CN=GSPA Intermediate");
CaConfigDtoType.GetProperty("CommonName").SetValue(cfg, "GSPA Intermediate");
CaConfigDtoType.GetProperty("OrganizationName").SetValue(cfg, "GSPA");
CaConfigDtoType.GetProperty("OrganizationUnit").SetValue(cfg, "MECM");
CaConfigDtoType.GetProperty("Country").SetValue(cfg, "US");
CaConfigDtoType.GetProperty("KeyAlgorithm").SetValue(cfg, "RSA_2048");
CaConfigDtoType.GetProperty("DistinguishedName").SetValue(cfg, "CN=GSPA Intermediate");
CaConfigDtoType.GetProperty("SerialNumber").SetValue(cfg, "74a4b62197ad");
CaConfigDtoType.GetProperty("MaxPathLength").SetValue(cfg, 0);
CaConfigDtoType.GetProperty("Type").SetValue(cfg, "intermediate");
object dto = Activator.CreateInstance(CaDtoType);
CaDtoType.GetProperty("Id").SetValue(dto, "ca-9");
CaDtoType.GetProperty("Name").SetValue(dto, "intermediate-ca");
CaDtoType.GetProperty("Type").SetValue(dto, "internal");
CaDtoType.GetProperty("Status").SetValue(dto, "active");
CaDtoType.GetProperty("Configuration").SetValue(dto, cfg);
InfisicalCertificateAuthority mapped = InvokeCaMap(dto, "proj-fallback");
Assert.Equal("ca-9", mapped.Id);
Assert.Equal("intermediate-ca", mapped.Name);
Assert.Equal("internal", mapped.Type);
Assert.Equal("C=US, CN=GSPA Intermediate", mapped.FriendlyName);
Assert.Equal("GSPA Intermediate", mapped.CommonName);
Assert.Equal("GSPA", mapped.OrganizationName);
Assert.Equal("MECM", mapped.OrganizationUnit);
Assert.Equal("US", mapped.Country);
Assert.Equal("RSA_2048", mapped.KeyAlgorithm);
Assert.Equal("CN=GSPA Intermediate", mapped.DistinguishedName);
Assert.Equal("74a4b62197ad", mapped.SerialNumber);
Assert.Equal(0, mapped.MaxPathLength);
}
[Fact]
public void BundleMap_Maps_All_Pem_Fields()
{
@@ -0,0 +1,479 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Management.Automation;
using System.Reflection;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Pki;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class CsrAndRequestCmdletTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
[Fact]
public void CsrBuilder_Rsa2048_Produces_Pem_Csr_And_PrivateKey_With_Subject_And_Sans()
{
InfisicalCsrSubject subject = new InfisicalCsrSubject
{
CommonName = "test.contoso.local",
Organization = "Contoso",
Country = "US"
};
InfisicalCsrOptions options = new InfisicalCsrOptions { KeyAlgorithm = InfisicalKeyAlgorithm.Rsa, RsaKeySize = 2048 };
InfisicalCsrResult result = InfisicalCsrBuilder.Build(subject, new[] { "test.contoso.local", "alt.contoso.local" }, new[] { "10.0.0.5" }, options);
Assert.NotNull(result);
Assert.Contains("BEGIN CERTIFICATE REQUEST", result.CsrPem);
Assert.Contains("END CERTIFICATE REQUEST", result.CsrPem);
Assert.Contains("BEGIN RSA PRIVATE KEY", result.PrivateKeyPem);
Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest pkcs10 = ReadCsr(result.CsrPem);
Assert.True(pkcs10.Verify());
Org.BouncyCastle.Crypto.Parameters.RsaKeyParameters rsa = Assert.IsAssignableFrom<Org.BouncyCastle.Crypto.Parameters.RsaKeyParameters>(pkcs10.GetPublicKey());
Assert.Equal(2048, rsa.Modulus.BitLength);
}
[Theory]
[InlineData(InfisicalEcCurve.P256, "1.2.840.10045.3.1.7")]
[InlineData(InfisicalEcCurve.P384, "1.3.132.0.34")]
public void CsrBuilder_Ecdsa_Produces_Verifiable_Csr(InfisicalEcCurve curve, string expectedCurveOid)
{
InfisicalCsrSubject subject = new InfisicalCsrSubject { CommonName = "ec.contoso.local" };
InfisicalCsrOptions options = new InfisicalCsrOptions { KeyAlgorithm = InfisicalKeyAlgorithm.Ecdsa, EcCurve = curve };
InfisicalCsrResult result = InfisicalCsrBuilder.Build(subject, new[] { "ec.contoso.local" }, null, options);
Assert.Contains("BEGIN CERTIFICATE REQUEST", result.CsrPem);
Assert.True(result.PrivateKeyPem.Contains("BEGIN EC PRIVATE KEY") || result.PrivateKeyPem.Contains("BEGIN PRIVATE KEY"));
Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest pkcs10 = ReadCsr(result.CsrPem);
Assert.True(pkcs10.Verify());
Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters ec = Assert.IsAssignableFrom<Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters>(pkcs10.GetPublicKey());
Assert.Equal(expectedCurveOid, ec.PublicKeyParamSet.Id);
}
[Fact]
public void CsrBuilder_Ed25519_Produces_Verifiable_Csr()
{
InfisicalCsrSubject subject = new InfisicalCsrSubject { CommonName = "ed.contoso.local" };
InfisicalCsrOptions options = new InfisicalCsrOptions { KeyAlgorithm = InfisicalKeyAlgorithm.Ed25519 };
InfisicalCsrResult result = InfisicalCsrBuilder.Build(subject, new[] { "ed.contoso.local" }, null, options);
Assert.Contains("BEGIN CERTIFICATE REQUEST", result.CsrPem);
Assert.Contains("BEGIN PRIVATE KEY", result.PrivateKeyPem);
Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest pkcs10 = ReadCsr(result.CsrPem);
Assert.True(pkcs10.Verify());
Assert.IsAssignableFrom<Org.BouncyCastle.Crypto.Parameters.Ed25519PublicKeyParameters>(pkcs10.GetPublicKey());
}
[Fact]
public void CsrBuilder_Rsa_Rejects_Invalid_KeySize()
{
InfisicalCsrSubject subject = new InfisicalCsrSubject { CommonName = "test.local" };
InfisicalCsrOptions options = new InfisicalCsrOptions { KeyAlgorithm = InfisicalKeyAlgorithm.Rsa, RsaKeySize = 1024 };
Assert.Throws<ArgumentException>(() => InfisicalCsrBuilder.Build(subject, null, null, options));
}
[Fact]
public void CsrBuilder_Throws_When_CommonName_Missing()
{
InfisicalCsrSubject subject = new InfisicalCsrSubject { Organization = "Contoso" };
Assert.Throws<ArgumentException>(() => InfisicalCsrBuilder.Build(subject, null, null, new InfisicalCsrOptions()));
}
private static Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest ReadCsr(string pem)
{
using (System.IO.StringReader reader = new System.IO.StringReader(pem))
{
Org.BouncyCastle.OpenSsl.PemReader pemReader = new Org.BouncyCastle.OpenSsl.PemReader(reader);
object obj = pemReader.ReadObject();
return Assert.IsType<Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest>(obj);
}
}
[Fact]
public void MergeSubject_Hashtable_Then_Individual_Params_Override()
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(merge);
Hashtable subject = new Hashtable { { "CN", "fallback.local" }, { "O", "FallbackOrg" }, { "C", "DE" } };
object result = merge.Invoke(null, new object[] { subject, "explicit.local", null, null, null, "ExplicitOrg", null, null });
PropertyInfo commonNameProp = result.GetType().GetProperty("CommonName");
PropertyInfo organizationProp = result.GetType().GetProperty("Organization");
PropertyInfo countryProp = result.GetType().GetProperty("Country");
Assert.Equal("explicit.local", commonNameProp.GetValue(result));
Assert.Equal("ExplicitOrg", organizationProp.GetValue(result));
Assert.Equal("DE", countryProp.GetValue(result));
}
[Fact]
public void Candidates_For_SignCertificateBySubscriber_Include_Pki_And_CertManager()
{
IReadOnlyList<InfisicalEndpointDefinition> candidates = InfisicalEndpointRegistry.GetCandidates(InfisicalEndpointNames.SignCertificateBySubscriber);
Assert.Contains(candidates, c => c.Template == "/api/v1/pki/pki-subscribers/{subscriberName}/sign-certificate");
Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/pki-subscribers/{subscriberName}/sign-certificate");
foreach (InfisicalEndpointDefinition candidate in candidates)
{
Assert.Equal("POST", candidate.Method);
Assert.True(candidate.RequiresAuthorization);
Assert.True(candidate.ContainsSecretMaterialInResponse);
}
}
[Fact]
public void Candidates_For_SignCertificateByCa_Include_Pki_And_CertManager()
{
IReadOnlyList<InfisicalEndpointDefinition> candidates = InfisicalEndpointRegistry.GetCandidates(InfisicalEndpointNames.SignCertificateByCa);
Assert.Contains(candidates, c => c.Template == "/api/v1/pki/ca/{caId}/sign-certificate");
Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/ca/{caId}/sign-certificate");
}
[Fact]
public void RequestInfisicalCertificate_Cmdlet_Has_Both_Parameter_Sets()
{
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
Assert.True(typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(cmdletType));
CustomAttributeData cmdletData = null;
foreach (CustomAttributeData candidate in cmdletType.GetCustomAttributesData())
{
if (candidate.AttributeType == typeof(CmdletAttribute)) { cmdletData = candidate; break; }
}
Assert.NotNull(cmdletData);
Assert.Equal(VerbsLifecycle.Request, cmdletData.ConstructorArguments[0].Value);
Assert.Equal("InfisicalCertificate", cmdletData.ConstructorArguments[1].Value);
string defaultParameterSetName = null;
foreach (CustomAttributeNamedArgument named in cmdletData.NamedArguments)
{
if (named.MemberName == "DefaultParameterSetName") { defaultParameterSetName = (string)named.TypedValue.Value; break; }
}
Assert.Equal("BySubscriber", defaultParameterSetName);
Assert.NotNull(cmdletType.GetProperty("PkiSubscriberSlug"));
Assert.NotNull(cmdletType.GetProperty("CertificateAuthorityId"));
Assert.NotNull(cmdletType.GetProperty("Subject"));
Assert.NotNull(cmdletType.GetProperty("CommonName"));
Assert.NotNull(cmdletType.GetProperty("DnsName"));
Assert.NotNull(cmdletType.GetProperty("IpAddress"));
Assert.NotNull(cmdletType.GetProperty("Install"));
Assert.NotNull(cmdletType.GetProperty("StoreName"));
Assert.NotNull(cmdletType.GetProperty("StoreLocation"));
Assert.NotNull(cmdletType.GetProperty("AllowRenewal"));
Assert.NotNull(cmdletType.GetProperty("RenewalThresholdDays"));
Assert.NotNull(cmdletType.GetProperty("Force"));
Assert.NotNull(cmdletType.GetProperty("InstallChain"));
PropertyInfo keyAlgorithmProp = cmdletType.GetProperty("KeyAlgorithm");
PropertyInfo curveProp = cmdletType.GetProperty("Curve");
Assert.NotNull(keyAlgorithmProp);
Assert.NotNull(curveProp);
Assert.Equal(typeof(InfisicalKeyAlgorithm), keyAlgorithmProp.PropertyType);
Assert.Equal(typeof(InfisicalEcCurve), curveProp.PropertyType);
PropertyInfo protectionProp = cmdletType.GetProperty("PrivateKeyProtection");
Assert.NotNull(protectionProp);
Assert.Equal(typeof(InfisicalPrivateKeyProtection), protectionProp.PropertyType);
Assert.NotNull(cmdletType.GetProperty("PersistKey"));
Assert.NotNull(cmdletType.GetProperty("MachineKey"));
Assert.NotNull(cmdletType.GetProperty("PrivateKeyPath"));
Assert.NotNull(cmdletType.GetProperty("LocalChainOnly"));
CustomAttributeData outputTypeData = null;
foreach (CustomAttributeData candidate in cmdletType.GetCustomAttributesData())
{
if (candidate.AttributeType == typeof(OutputTypeAttribute)) { outputTypeData = candidate; break; }
}
Assert.NotNull(outputTypeData);
IList<CustomAttributeTypedArgument> outputTypeArgs = (IList<CustomAttributeTypedArgument>)outputTypeData.ConstructorArguments[0].Value;
Assert.Contains(outputTypeArgs, a => (Type)a.Value == typeof(PSInfisicalAPI.Models.InfisicalCertificateResult));
}
[Fact]
public void BuildResult_Splits_Chain_Into_Leaf_Intermediates_And_Root()
{
(string leafPem, _, string leafThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("BuildResult.Leaf");
(string intermediatePem, _, string intermediateThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("BuildResult.Intermediate");
(string rootPem, _, string rootThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("BuildResult.Root");
PSInfisicalAPI.Models.InfisicalSignedCertificate signed = new PSInfisicalAPI.Models.InfisicalSignedCertificate
{
SerialNumber = "ABC123",
CertificatePem = leafPem,
CertificateChainPem = intermediatePem + rootPem,
IssuingCaCertificatePem = rootPem
};
using (System.Security.Cryptography.X509Certificates.X509Certificate2 leaf = new System.Security.Cryptography.X509Certificates.X509Certificate2(System.Text.Encoding.ASCII.GetBytes(leafPem)))
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo buildResult = helperType.GetMethod("BuildResult", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(buildResult);
PSInfisicalAPI.Models.InfisicalCertificateResult result = (PSInfisicalAPI.Models.InfisicalCertificateResult)buildResult.Invoke(null, new object[] { leaf, signed });
Assert.Same(leaf, result.Leaf);
Assert.Equal("ABC123", result.SerialNumber);
Assert.Empty(result.Intermediates);
Assert.NotNull(result.Root);
Assert.Equal(2, result.Chain.Length);
Assert.Same(leaf, result.Chain[0]);
}
}
[Theory]
[InlineData(InfisicalPrivateKeyProtection.LocalOnly, false, false, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.DefaultKeySet)]
[InlineData(InfisicalPrivateKeyProtection.Exportable, false, false, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.Exportable)]
[InlineData(InfisicalPrivateKeyProtection.NonExportable, false, false, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.DefaultKeySet)]
[InlineData(InfisicalPrivateKeyProtection.LocalOnly, true, false, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.PersistKeySet)]
[InlineData(InfisicalPrivateKeyProtection.LocalOnly, false, true, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.MachineKeySet)]
[InlineData(InfisicalPrivateKeyProtection.Exportable, true, true, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.Exportable | System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.MachineKeySet | System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.PersistKeySet)]
public void ResolveKeyStorageFlags_Maps_Protection_And_Switches(InfisicalPrivateKeyProtection protection, bool persist, bool machine, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags expected)
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo method = helperType.GetMethod("ResolveKeyStorageFlags", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
System.Security.Cryptography.X509Certificates.X509KeyStorageFlags actual = (System.Security.Cryptography.X509Certificates.X509KeyStorageFlags)method.Invoke(null, new object[] { protection, persist, machine });
Assert.Equal(expected, actual);
}
[Theory]
[InlineData(InfisicalPrivateKeyProtection.LocalOnly, false, false)]
[InlineData(InfisicalPrivateKeyProtection.Exportable, false, false)]
[InlineData(InfisicalPrivateKeyProtection.NonExportable, false, true)]
[InlineData(InfisicalPrivateKeyProtection.Ephemeral, false, true)]
[InlineData(InfisicalPrivateKeyProtection.LocalOnly, true, true)]
[InlineData(InfisicalPrivateKeyProtection.Exportable, true, true)]
public void ShouldScrubPrivateKeyPem_Returns_Expected(InfisicalPrivateKeyProtection protection, bool hasPath, bool expected)
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo method = helperType.GetMethod("ShouldScrubPrivateKeyPem", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
bool actual = (bool)method.Invoke(null, new object[] { protection, hasPath });
Assert.Equal(expected, actual);
}
[Fact]
public void WritePrivateKeyPem_Writes_File_And_Creates_Directory()
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo method = helperType.GetMethod("WritePrivateKeyPem", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
string tempRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "PSInfisicalAPI_PemWrite_" + Guid.NewGuid().ToString("N"));
string nested = System.IO.Path.Combine(tempRoot, "nested", "key.pem");
const string pem = "-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBgkqhkiG9w0BAQEFAA==\n-----END PRIVATE KEY-----\n";
try
{
method.Invoke(null, new object[] { pem, nested });
Assert.True(System.IO.File.Exists(nested));
Assert.Equal(pem, System.IO.File.ReadAllText(nested));
}
finally
{
if (System.IO.Directory.Exists(tempRoot)) { System.IO.Directory.Delete(tempRoot, true); }
}
}
[Fact]
public void BuildResultFromExistingLocal_Populates_Leaf_And_Pem_For_Selfsigned()
{
(string leafPem, _, string leafThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("ReuseLookup.Leaf");
using (System.Security.Cryptography.X509Certificates.X509Certificate2 leaf = new System.Security.Cryptography.X509Certificates.X509Certificate2(System.Text.Encoding.ASCII.GetBytes(leafPem)))
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo build = helperType.GetMethod("BuildResultFromExistingLocal", BindingFlags.Public | BindingFlags.Static, null, new Type[] { typeof(System.Security.Cryptography.X509Certificates.X509Certificate2) }, null);
Assert.NotNull(build);
PSInfisicalAPI.Models.InfisicalCertificateResult result = (PSInfisicalAPI.Models.InfisicalCertificateResult)build.Invoke(null, new object[] { leaf });
Assert.Same(leaf, result.Leaf);
Assert.Equal(leaf.SerialNumber, result.SerialNumber);
Assert.Contains("BEGIN CERTIFICATE", result.CertificatePem);
Assert.NotNull(result.Chain);
Assert.NotEmpty(result.Chain);
Assert.Same(leaf, result.Chain[0]);
Assert.Empty(result.Intermediates);
}
}
[Fact]
public void BuildResultFromExistingLocal_Has_Bundle_Fallback_Overload()
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo overload = helperType.GetMethod(
"BuildResultFromExistingLocal",
BindingFlags.Public | BindingFlags.Static,
null,
new Type[] { typeof(System.Security.Cryptography.X509Certificates.X509Certificate2), typeof(PSInfisicalAPI.Models.InfisicalCertificateBundle) },
null);
Assert.NotNull(overload);
Assert.Equal(typeof(PSInfisicalAPI.Models.InfisicalCertificateResult), overload.ReturnType);
}
[Fact]
public void BuildResultFromExistingLocal_With_Null_Bundle_Matches_LocalOnly_Behavior()
{
(string leafPem, _, _) = PemCertificateBuilderTests.CreateSelfSignedExposed("ReuseLookup.Bundle.Null.Leaf");
using (System.Security.Cryptography.X509Certificates.X509Certificate2 leaf = new System.Security.Cryptography.X509Certificates.X509Certificate2(System.Text.Encoding.ASCII.GetBytes(leafPem)))
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo overload = helperType.GetMethod(
"BuildResultFromExistingLocal",
BindingFlags.Public | BindingFlags.Static,
null,
new Type[] { typeof(System.Security.Cryptography.X509Certificates.X509Certificate2), typeof(PSInfisicalAPI.Models.InfisicalCertificateBundle) },
null);
PSInfisicalAPI.Models.InfisicalCertificateResult result = (PSInfisicalAPI.Models.InfisicalCertificateResult)overload.Invoke(null, new object[] { leaf, null });
Assert.Same(leaf, result.Leaf);
Assert.Empty(result.Intermediates);
Assert.Single(result.Chain);
}
}
[Fact]
public void BuildResultFromExistingLocal_With_Bundle_Merges_Chain_From_Bundle()
{
(string leafPem, _, string leafThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("ReuseLookup.Bundle.Leaf");
(string caPem, _, string caThumb) = PemCertificateBuilderTests.CreateSelfSignedExposed("ReuseLookup.Bundle.Ca");
using (System.Security.Cryptography.X509Certificates.X509Certificate2 leaf = new System.Security.Cryptography.X509Certificates.X509Certificate2(System.Text.Encoding.ASCII.GetBytes(leafPem)))
{
PSInfisicalAPI.Models.InfisicalCertificateBundle bundle = new PSInfisicalAPI.Models.InfisicalCertificateBundle
{
SerialNumber = leaf.SerialNumber,
CertificatePem = leafPem,
CertificateChainPem = caPem
};
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo overload = helperType.GetMethod(
"BuildResultFromExistingLocal",
BindingFlags.Public | BindingFlags.Static,
null,
new Type[] { typeof(System.Security.Cryptography.X509Certificates.X509Certificate2), typeof(PSInfisicalAPI.Models.InfisicalCertificateBundle) },
null);
PSInfisicalAPI.Models.InfisicalCertificateResult result = (PSInfisicalAPI.Models.InfisicalCertificateResult)overload.Invoke(null, new object[] { leaf, bundle });
Assert.Same(leaf, result.Leaf);
Assert.NotNull(result.Root);
Assert.Equal(caThumb, result.Root.Thumbprint);
Assert.Equal(2, result.Chain.Length);
Assert.Same(leaf, result.Chain[0]);
Assert.Equal(caThumb, result.Chain[1].Thumbprint);
Assert.NotNull(result.CertificateChainPem);
Assert.Contains("BEGIN CERTIFICATE", result.CertificateChainPem);
}
}
[Fact]
public void GetChainCertificateTargetStore_SelfSigned_Returns_Root()
{
using (System.Security.Cryptography.RSA rsa = System.Security.Cryptography.RSA.Create(2048))
{
System.Security.Cryptography.X509Certificates.CertificateRequest request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=ChainRouting.SelfSigned",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
using (System.Security.Cryptography.X509Certificates.X509Certificate2 selfSigned = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)))
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo classify = helperType.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(classify);
object result = classify.Invoke(null, new object[] { selfSigned });
Assert.Equal(System.Security.Cryptography.X509Certificates.StoreName.Root, result);
}
}
}
[Fact]
public void GetChainCertificateTargetStore_NonSelfSigned_Returns_CertificateAuthority()
{
using (System.Security.Cryptography.RSA rootRsa = System.Security.Cryptography.RSA.Create(2048))
using (System.Security.Cryptography.RSA intermediateRsa = System.Security.Cryptography.RSA.Create(2048))
{
System.Security.Cryptography.X509Certificates.CertificateRequest rootRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=ChainRouting.Root",
rootRsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true));
using (System.Security.Cryptography.X509Certificates.X509Certificate2 rootCert = rootRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)))
{
System.Security.Cryptography.X509Certificates.CertificateRequest intermediateRequest = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=ChainRouting.Intermediate",
intermediateRsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
intermediateRequest.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true));
byte[] serial = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
using (System.Security.Cryptography.X509Certificates.X509Certificate2 intermediate = intermediateRequest.Create(rootCert, DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1), serial))
{
Assert.NotEqual(intermediate.Subject, intermediate.Issuer);
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo classify = helperType.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
object result = classify.Invoke(null, new object[] { intermediate });
Assert.Equal(System.Security.Cryptography.X509Certificates.StoreName.CertificateAuthority, result);
}
}
}
}
[Fact]
public void InstallChain_Has_X509Collection_Overload()
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
Type loggerType = ModuleAssembly.GetType("PSInfisicalAPI.Logging.IInfisicalLogger", true);
MethodInfo overload = helperType.GetMethod(
"InstallChain",
BindingFlags.Public | BindingFlags.Static,
null,
new Type[]
{
typeof(System.Collections.Generic.IEnumerable<System.Security.Cryptography.X509Certificates.X509Certificate2>),
typeof(System.Security.Cryptography.X509Certificates.StoreLocation),
typeof(bool),
loggerType,
typeof(string)
},
null);
Assert.NotNull(overload);
Assert.Equal(typeof(void), overload.ReturnType);
}
[Fact]
public void InstallInfisicalCertificateCmdlet_Uses_ChainRouting_Helper()
{
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.InstallInfisicalCertificateCmdlet", true);
Assert.NotNull(cmdletType);
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo classify = helperType.GetMethod("GetChainCertificateTargetStore", BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(classify);
}
}
}
@@ -8,6 +8,11 @@ namespace PSInfisicalAPI.Tests
{
public class PemCertificateBuilderTests
{
public static (string CertPem, string KeyPem, string Thumbprint) CreateSelfSignedExposed(string commonName)
{
return CreateSelfSigned(commonName);
}
private static (string CertPem, string KeyPem, string Thumbprint) CreateSelfSigned(string commonName)
{
using (RSA rsa = RSA.Create(2048))
@@ -0,0 +1,86 @@
using System;
using System.Collections;
using System.Reflection;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Pki;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class PkiClientParseTests
{
private sealed class NoopHttpClient : IInfisicalHttpClient
{
public InfisicalHttpResponse Send(InfisicalHttpRequest request) { throw new NotImplementedException(); }
}
private static InfisicalPkiClient CreateClient()
{
return new InfisicalPkiClient(new NoopHttpClient(), NullInfisicalLogger.Instance);
}
private static object InvokeNonPublic(InfisicalPkiClient client, string methodName, string body)
{
MethodInfo method = typeof(InfisicalPkiClient).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
return method.Invoke(client, new object[] { body });
}
[Fact]
public void ParseCaListBody_Reads_Raw_Json_Array()
{
string body = "[{\"id\":\"ca-1\",\"name\":\"intermediate\",\"projectId\":\"p1\",\"configuration\":{\"commonName\":\"Intermediate CA\",\"keyAlgorithm\":\"RSA_2048\"}}]";
object result = InvokeNonPublic(CreateClient(), "ParseCaListBody", body);
IList list = (IList)result;
Assert.Single(list);
object dto = list[0];
Assert.Equal("ca-1", dto.GetType().GetProperty("Id").GetValue(dto));
object cfg = dto.GetType().GetProperty("Configuration").GetValue(dto);
Assert.NotNull(cfg);
Assert.Equal("Intermediate CA", cfg.GetType().GetProperty("CommonName").GetValue(cfg));
}
[Fact]
public void ParseCaListBody_Reads_CertificateAuthorities_Wrapper()
{
string body = "{\"certificateAuthorities\":[{\"id\":\"ca-2\",\"name\":\"root\"}]}";
object result = InvokeNonPublic(CreateClient(), "ParseCaListBody", body);
IList list = (IList)result;
Assert.Single(list);
object dto = list[0];
Assert.Equal("ca-2", dto.GetType().GetProperty("Id").GetValue(dto));
}
[Fact]
public void ParseCaSingleBody_Reads_Raw_Object_With_Configuration()
{
string body = "{\"id\":\"ca-9\",\"name\":\"intermediate-ca\",\"status\":\"active\",\"configuration\":{\"commonName\":\"GSPA Intermediate\",\"organization\":\"GSPA\"}}";
object result = InvokeNonPublic(CreateClient(), "ParseCaSingleBody", body);
Assert.NotNull(result);
Assert.Equal("ca-9", result.GetType().GetProperty("Id").GetValue(result));
object cfg = result.GetType().GetProperty("Configuration").GetValue(result);
Assert.NotNull(cfg);
Assert.Equal("GSPA Intermediate", cfg.GetType().GetProperty("CommonName").GetValue(cfg));
Assert.Equal("GSPA", cfg.GetType().GetProperty("OrganizationName").GetValue(cfg));
}
[Fact]
public void ParseCaSingleBody_Reads_CertificateAuthority_Wrapper()
{
string body = "{\"certificateAuthority\":{\"id\":\"ca-7\",\"name\":\"root\"}}";
object result = InvokeNonPublic(CreateClient(), "ParseCaSingleBody", body);
Assert.NotNull(result);
Assert.Equal("ca-7", result.GetType().GetProperty("Id").GetValue(result));
}
[Fact]
public void ParseCertificateSingleBody_Reads_Certificate_Wrapper()
{
string body = "{\"certificate\":{\"id\":\"cert-1\",\"serialNumber\":\"ABCD\",\"commonName\":\"host.example\"}}";
object result = InvokeNonPublic(CreateClient(), "ParseCertificateSingleBody", body);
Assert.NotNull(result);
Assert.Equal("cert-1", result.GetType().GetProperty("Id").GetValue(result));
Assert.Equal("ABCD", result.GetType().GetProperty("SerialNumber").GetValue(result));
}
}
}
@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Reflection;
using PSInfisicalAPI.Endpoints;
using Xunit;
@@ -6,6 +9,64 @@ namespace PSInfisicalAPI.Tests
{
public class PkiEndpointRegistryTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
[Fact]
public void GetInfisicalCertificate_Cmdlet_Is_Singular_With_Mandatory_SerialNumber()
{
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateCmdlet", true);
Assert.True(typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(cmdletType));
CustomAttributeData cmdletData = null;
foreach (CustomAttributeData candidate in cmdletType.GetCustomAttributesData())
{
if (candidate.AttributeType == typeof(CmdletAttribute)) { cmdletData = candidate; break; }
}
Assert.NotNull(cmdletData);
Assert.Equal(2, cmdletData.ConstructorArguments.Count);
Assert.Equal(VerbsCommon.Get, cmdletData.ConstructorArguments[0].Value);
Assert.Equal("InfisicalCertificate", cmdletData.ConstructorArguments[1].Value);
PropertyInfo serialProp = cmdletType.GetProperty("SerialNumber");
Assert.NotNull(serialProp);
CustomAttributeData parameterAttr = null;
foreach (CustomAttributeData candidate in serialProp.GetCustomAttributesData())
{
if (candidate.AttributeType == typeof(ParameterAttribute)) { parameterAttr = candidate; break; }
}
Assert.NotNull(parameterAttr);
bool mandatory = false;
foreach (CustomAttributeNamedArgument named in parameterAttr.NamedArguments)
{
if (named.MemberName == "Mandatory") { mandatory = (bool)named.TypedValue.Value; break; }
}
Assert.True(mandatory);
}
[Fact]
public void GetInfisicalCertificates_Cmdlet_Is_Registered_For_Listing()
{
Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificatesCmdlet", true);
Assert.True(typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(cmdletType));
CustomAttributeData cmdletData = null;
foreach (CustomAttributeData candidate in cmdletType.GetCustomAttributesData())
{
if (candidate.AttributeType == typeof(CmdletAttribute)) { cmdletData = candidate; break; }
}
Assert.NotNull(cmdletData);
Assert.Equal(VerbsCommon.Get, cmdletData.ConstructorArguments[0].Value);
Assert.Equal("InfisicalCertificates", cmdletData.ConstructorArguments[1].Value);
Assert.NotNull(cmdletType.GetProperty("CommonName"));
Assert.NotNull(cmdletType.GetProperty("FriendlyName"));
Assert.NotNull(cmdletType.GetProperty("CaId"));
Assert.NotNull(cmdletType.GetProperty("Limit"));
Assert.NotNull(cmdletType.GetProperty("Offset"));
}
[Fact]
public void Get_ListInternalCertificateAuthorities_Returns_CertManager_Primary()
{
@@ -21,12 +21,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
string resolvedProjectId = ResolveProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal))
{
InfisicalCertificateAuthority ca = client.GetInternalCertificateAuthority(connection, CaId, resolvedProjectId);
InfisicalCertificateAuthority ca = client.GetInternalCertificateAuthority(connection, CaId, ProjectId);
if (ca != null)
{
WriteObject(ca);
@@ -35,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
return;
}
InfisicalCertificateAuthority[] all = client.ListInternalCertificateAuthorities(connection, resolvedProjectId);
InfisicalCertificateAuthority[] all = client.ListInternalCertificateAuthorities(connection, ProjectId);
foreach (InfisicalCertificateAuthority ca in all)
{
WriteObject(ca);
@@ -0,0 +1,36 @@
using System;
using System.Management.Automation;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Pki;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "InfisicalCertificate")]
[OutputType(typeof(InfisicalCertificate))]
public sealed class GetInfisicalCertificateCmdlet : InfisicalCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
[Alias("Id", "Identifier")]
public string SerialNumber { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
InfisicalCertificate cert = client.RetrieveCertificate(connection, SerialNumber);
if (cert != null)
{
WriteObject(cert);
}
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception);
}
}
}
}
@@ -0,0 +1,76 @@
using System;
using System.Management.Automation;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Pki;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "InfisicalCertificates")]
[OutputType(typeof(InfisicalCertificate))]
public sealed class GetInfisicalCertificatesCmdlet : InfisicalCmdletBase
{
[Parameter] public string ProjectId { get; set; }
[Parameter] public string CommonName { get; set; }
[Parameter] public string FriendlyName { get; set; }
[Parameter] public string Status { get; set; }
[Parameter] public string[] CaId { get; set; }
[Parameter] public int? Limit { get; set; }
[Parameter] public int? Offset { get; set; }
[Parameter] public SwitchParameter NoAutoPage { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
string resolvedProjectId = ResolveProjectId(connection, ProjectId);
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery
{
ProjectId = resolvedProjectId,
CommonName = CommonName,
FriendlyName = FriendlyName,
Status = Status,
CaIds = CaId,
Limit = Limit ?? 100,
Offset = Offset ?? 0
};
int requestedLimit = query.Limit ?? 100;
int emitted = 0;
while (true)
{
InfisicalCertificateSearchResult page = client.SearchCertificates(connection, query);
if (page == null || page.Certificates == null || page.Certificates.Length == 0)
{
break;
}
foreach (InfisicalCertificate cert in page.Certificates)
{
WriteObject(cert);
emitted++;
}
if (NoAutoPage.IsPresent || page.Certificates.Length < requestedLimit)
{
break;
}
if (page.TotalCount > 0 && emitted >= page.TotalCount)
{
break;
}
query.Offset = (query.Offset ?? 0) + page.Certificates.Length;
}
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificatesCmdlet", "GetCertificates", exception);
}
}
}
}
@@ -44,7 +44,8 @@ namespace PSInfisicalAPI.Cmdlets
{
foreach (X509Certificate2 chainCert in ResolveChain())
{
InstallCertificate(chainCert, StoreName.CertificateAuthority, StoreLocation);
StoreName chainStore = InfisicalCertificateRequestHelpers.GetChainCertificateTargetStore(chainCert);
InstallCertificate(chainCert, chainStore, StoreLocation);
}
}
@@ -0,0 +1,204 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Management.Automation;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Pki;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsLifecycle.Request, "InfisicalCertificate", SupportsShouldProcess = true, DefaultParameterSetName = "BySubscriber")]
[OutputType(typeof(InfisicalCertificateResult))]
public sealed class RequestInfisicalCertificateCmdlet : InfisicalCmdletBase
{
private const string Component = "RequestInfisicalCertificateCmdlet";
[Parameter(ParameterSetName = "BySubscriber", Mandatory = true, Position = 0)]
[Alias("Subscriber")]
public string PkiSubscriberSlug { get; set; }
[Parameter(ParameterSetName = "ByCa", Mandatory = true, Position = 0)]
[Alias("CaId")]
public string CertificateAuthorityId { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter] public IDictionary Subject { get; set; }
[Parameter] public string CommonName { get; set; }
[Parameter] public string Country { get; set; }
[Parameter] public string State { get; set; }
[Parameter] public string Locality { get; set; }
[Parameter] public string Organization { get; set; }
[Parameter] public string OrganizationalUnit { get; set; }
[Parameter] public string EmailAddress { get; set; }
[Parameter] public string[] DnsName { get; set; }
[Parameter] public string[] IpAddress { get; set; }
[Parameter] public InfisicalKeyAlgorithm KeyAlgorithm { get; set; } = InfisicalKeyAlgorithm.Rsa;
[Parameter] public int KeySize { get; set; } = 2048;
[Parameter] public InfisicalEcCurve Curve { get; set; } = InfisicalEcCurve.P256;
[Parameter(ParameterSetName = "ByCa")] public string Ttl { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string NotBefore { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string NotAfter { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string FriendlyName { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string[] KeyUsage { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string[] ExtendedKeyUsage { get; set; }
[Parameter] public SwitchParameter Install { get; set; }
[Parameter] public StoreName StoreName { get; set; } = StoreName.My;
[Parameter] public StoreLocation StoreLocation { get; set; } = StoreLocation.CurrentUser;
[Parameter] public X509KeyStorageFlags KeyStorageFlags { get; set; } = X509KeyStorageFlags.DefaultKeySet;
[Parameter] public SwitchParameter InstallChain { get; set; }
[Parameter] public InfisicalPrivateKeyProtection PrivateKeyProtection { get; set; } = InfisicalPrivateKeyProtection.LocalOnly;
[Parameter] public SwitchParameter PersistKey { get; set; }
[Parameter] public SwitchParameter MachineKey { get; set; }
[Parameter] public string PrivateKeyPath { get; set; }
[Parameter] public SwitchParameter AllowRenewal { get; set; }
[Parameter] public int RenewalThresholdDays { get; set; } = 30;
[Parameter] public SwitchParameter Force { get; set; }
[Parameter] public SwitchParameter LocalChainOnly { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
string resolvedProjectId = ResolveProjectId(connection, ProjectId);
InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
List<string> dnsNames = BuildDnsNames(csrSubject);
if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; }
if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); }
X509Certificate2 existing = TryFindExisting(client, connection, resolvedProjectId, csrSubject.CommonName);
if (existing != null && !Force.IsPresent && !(AllowRenewal.IsPresent && InfisicalLocalCertificateLookup.IsRenewable(existing, RenewalThresholdDays)))
{
Logger.Information(Component, string.Concat("Reusing existing certificate (Thumbprint=", existing.Thumbprint, ", NotAfter=", existing.NotAfter.ToString("u"), ")."));
InfisicalCertificateResult reuseResult = InfisicalCertificateRequestHelpers.BuildResultFromExistingLocal(existing);
if (!LocalChainOnly.IsPresent
&& (reuseResult.Root == null || reuseResult.Intermediates == null || reuseResult.Intermediates.Length == 0)
&& !string.IsNullOrEmpty(existing.SerialNumber))
{
try
{
InfisicalCertificateBundle bundle = client.GetCertificateBundle(connection, existing.SerialNumber);
if (bundle != null && !string.IsNullOrEmpty(bundle.CertificateChainPem))
{
reuseResult = InfisicalCertificateRequestHelpers.BuildResultFromExistingLocal(existing, bundle);
Logger.Information(Component, "Reused certificate chain completed from Infisical bundle.");
}
}
catch (Exception bundleException)
{
Logger.Verbose(Component, string.Concat("Infisical bundle fetch for reuse path failed (continuing with local-only chain): ", bundleException.Message));
}
}
WriteObject(reuseResult);
return;
}
string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "' or CA '", CertificateAuthorityId ?? "(n/a)", "' for CN=", csrSubject.CommonName);
if (!ShouldProcess(target, "Request new certificate")) { return; }
InfisicalCsrOptions csrOptions = new InfisicalCsrOptions { KeyAlgorithm = KeyAlgorithm, RsaKeySize = KeySize, EcCurve = Curve };
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, IpAddress, csrOptions);
InfisicalSignedCertificate signed = SignCertificate(client, connection, resolvedProjectId, csr.CsrPem);
signed.PrivateKeyPem = csr.PrivateKeyPem;
X509KeyStorageFlags resolvedFlags = ResolveEffectiveKeyStorageFlags();
X509Certificate2 cert = PemCertificateBuilder.Build(signed.CertificatePem, signed.PrivateKeyPem, signed.CertificateChainPem, resolvedFlags);
if (Install.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallToStore(cert, StoreName, StoreLocation, Force.IsPresent, Logger, Component);
if (InstallChain.IsPresent)
{
InfisicalCertificateRequestHelpers.InstallChain(signed, StoreLocation, Force.IsPresent, Logger, Component);
}
}
InfisicalCertificateResult resultObj = InfisicalCertificateRequestHelpers.BuildResult(cert, signed);
bool hasExplicitPath = !string.IsNullOrEmpty(PrivateKeyPath);
if (hasExplicitPath && !string.IsNullOrEmpty(resultObj.PrivateKeyPem))
{
InfisicalCertificateRequestHelpers.WritePrivateKeyPem(resultObj.PrivateKeyPem, PrivateKeyPath);
Logger.Information(Component, string.Concat("Wrote private key PEM to '", PrivateKeyPath, "'."));
}
if (!MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags")
&& InfisicalCertificateRequestHelpers.ShouldScrubPrivateKeyPem(PrivateKeyProtection, hasExplicitPath))
{
resultObj.PrivateKeyPem = null;
}
WriteObject(resultObj);
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "RequestCertificate", exception);
}
}
private List<string> BuildDnsNames(InfisicalCsrSubject subject)
{
List<string> result = new List<string>();
if (DnsName != null) { foreach (string dns in DnsName) { if (!string.IsNullOrEmpty(dns)) { result.Add(dns); } } }
if (result.Count == 0)
{
string fqdn = InfisicalCertificateRequestHelpers.ResolveLocalFqdn();
if (!string.IsNullOrEmpty(fqdn)) { result.Add(fqdn); }
}
if (!string.IsNullOrEmpty(subject.CommonName) && !result.Contains(subject.CommonName)) { result.Insert(0, subject.CommonName); }
return result;
}
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName)
{
List<string> candidateSerials = new List<string>();
try
{
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery { ProjectId = projectId, CommonName = commonName, Status = "active", Limit = 50 };
InfisicalCertificateSearchResult page = client.SearchCertificates(connection, query);
if (page != null && page.Certificates != null)
{
foreach (InfisicalCertificate hit in page.Certificates) { if (!string.IsNullOrEmpty(hit.SerialNumber)) { candidateSerials.Add(hit.SerialNumber); } }
}
}
catch (Exception searchException)
{
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
}
return InfisicalLocalCertificateLookup.FindMatch(StoreName, StoreLocation, commonName, candidateSerials);
}
private X509KeyStorageFlags ResolveEffectiveKeyStorageFlags()
{
if (MyInvocation.BoundParameters.ContainsKey("KeyStorageFlags"))
{
return KeyStorageFlags;
}
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent);
}
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem)
{
if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal))
{
return client.SignCertificateBySubscriber(connection, PkiSubscriberSlug, projectId, csrPem);
}
return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage);
}
}
}
@@ -49,5 +49,7 @@ namespace PSInfisicalAPI.Endpoints
public const string SearchCertificates = "SearchCertificates";
public const string RetrieveCertificate = "RetrieveCertificate";
public const string GetCertificateBundle = "GetCertificateBundle";
public const string SignCertificateBySubscriber = "SignCertificateBySubscriber";
public const string SignCertificateByCa = "SignCertificateByCa";
}
}
@@ -589,6 +589,50 @@ namespace PSInfisicalAPI.Endpoints
RequiresAuthorization = true,
ContainsSecretMaterialInResponse = true
});
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.SignCertificateBySubscriber,
Resource = "Pki",
Version = "v1",
Method = "POST",
Template = "/api/v1/pki/pki-subscribers/{subscriberName}/sign-certificate",
RequiresAuthorization = true,
ContainsSecretMaterialInResponse = true
});
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.SignCertificateBySubscriber,
Resource = "Pki",
Version = "v1",
Method = "POST",
Template = "/api/v1/cert-manager/pki-subscribers/{subscriberName}/sign-certificate",
RequiresAuthorization = true,
ContainsSecretMaterialInResponse = true
});
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.SignCertificateByCa,
Resource = "Pki",
Version = "v1",
Method = "POST",
Template = "/api/v1/pki/ca/{caId}/sign-certificate",
RequiresAuthorization = true,
ContainsSecretMaterialInResponse = true
});
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.SignCertificateByCa,
Resource = "Pki",
Version = "v1",
Method = "POST",
Template = "/api/v1/cert-manager/ca/{caId}/sign-certificate",
RequiresAuthorization = true,
ContainsSecretMaterialInResponse = true
});
}
public static InfisicalEndpointDefinition Get(string name)
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace PSInfisicalAPI.Models
{
public sealed class InfisicalCertificateResult
{
public X509Certificate2 Leaf { get; set; }
public X509Certificate2[] Intermediates { get; set; }
public X509Certificate2 Root { get; set; }
public X509Certificate2[] Chain { get; set; }
public string SerialNumber { get; set; }
public string CertificatePem { get; set; }
public string CertificateChainPem { get; set; }
public string PrivateKeyPem { get; set; }
public override string ToString()
{
if (Leaf != null) { return Leaf.Subject; }
return SerialNumber;
}
}
}
@@ -0,0 +1,16 @@
namespace PSInfisicalAPI.Models
{
public sealed class InfisicalSignedCertificate
{
public string SerialNumber { get; set; }
public string CertificatePem { get; set; }
public string CertificateChainPem { get; set; }
public string IssuingCaCertificatePem { get; set; }
public string PrivateKeyPem { get; set; }
public override string ToString()
{
return SerialNumber;
}
}
}
+21
View File
@@ -3,6 +3,26 @@ using Newtonsoft.Json;
namespace PSInfisicalAPI.Pki
{
internal sealed class InfisicalInternalCaConfigurationDto
{
[JsonProperty("type")] public string Type { get; set; }
[JsonProperty("friendlyName")] public string FriendlyName { get; set; }
[JsonProperty("commonName")] public string CommonName { get; set; }
[JsonProperty("organization")] public string OrganizationName { get; set; }
[JsonProperty("ou")] public string OrganizationUnit { get; set; }
[JsonProperty("country")] public string Country { get; set; }
[JsonProperty("province")] public string State { get; set; }
[JsonProperty("locality")] public string Locality { get; set; }
[JsonProperty("notBefore")] public string NotBefore { get; set; }
[JsonProperty("notAfter")] public string NotAfter { get; set; }
[JsonProperty("maxPathLength")] public int? MaxPathLength { get; set; }
[JsonProperty("keyAlgorithm")] public string KeyAlgorithm { get; set; }
[JsonProperty("dn")] public string DistinguishedName { get; set; }
[JsonProperty("parentCaId")] public string ParentCaId { get; set; }
[JsonProperty("serialNumber")] public string SerialNumber { get; set; }
[JsonProperty("activeCaCertId")] public string ActiveCaCertId { get; set; }
}
internal sealed class InfisicalInternalCaResponseDto
{
[JsonProperty("id")] public string Id { get; set; }
@@ -28,6 +48,7 @@ namespace PSInfisicalAPI.Pki
[JsonProperty("activeCaCertId")] public string ActiveCaCertId { get; set; }
[JsonProperty("createdAt")] public string CreatedAt { get; set; }
[JsonProperty("updatedAt")] public string UpdatedAt { get; set; }
[JsonProperty("configuration")] public InfisicalInternalCaConfigurationDto Configuration { get; set; }
}
internal sealed class InfisicalInternalCaListResponseDto
+23 -16
View File
@@ -14,34 +14,41 @@ namespace PSInfisicalAPI.Pki
return null;
}
InfisicalInternalCaConfigurationDto cfg = dto.Configuration;
return new InfisicalCertificateAuthority
{
Id = dto.Id,
ProjectId = !string.IsNullOrEmpty(dto.ProjectId) ? dto.ProjectId : fallbackProjectId,
Name = dto.Name,
FriendlyName = dto.FriendlyName,
Type = dto.Type,
FriendlyName = Coalesce(cfg != null ? cfg.FriendlyName : null, dto.FriendlyName),
Type = Coalesce(dto.Type, cfg != null ? cfg.Type : null),
Status = dto.Status,
EnableDirectIssuance = dto.EnableDirectIssuance,
KeyAlgorithm = dto.KeyAlgorithm,
DistinguishedName = dto.DistinguishedName,
OrganizationName = dto.OrganizationName,
OrganizationUnit = dto.OrganizationUnit,
Country = dto.Country,
State = dto.State,
Locality = dto.Locality,
CommonName = dto.CommonName,
MaxPathLength = dto.MaxPathLength,
NotBefore = dto.NotBefore,
NotAfter = dto.NotAfter,
SerialNumber = dto.SerialNumber,
ParentCaId = dto.ParentCaId,
ActiveCaCertId = dto.ActiveCaCertId,
KeyAlgorithm = Coalesce(cfg != null ? cfg.KeyAlgorithm : null, dto.KeyAlgorithm),
DistinguishedName = Coalesce(cfg != null ? cfg.DistinguishedName : null, dto.DistinguishedName),
OrganizationName = Coalesce(cfg != null ? cfg.OrganizationName : null, dto.OrganizationName),
OrganizationUnit = Coalesce(cfg != null ? cfg.OrganizationUnit : null, dto.OrganizationUnit),
Country = Coalesce(cfg != null ? cfg.Country : null, dto.Country),
State = Coalesce(cfg != null ? cfg.State : null, dto.State),
Locality = Coalesce(cfg != null ? cfg.Locality : null, dto.Locality),
CommonName = Coalesce(cfg != null ? cfg.CommonName : null, dto.CommonName),
MaxPathLength = (cfg != null && cfg.MaxPathLength.HasValue) ? cfg.MaxPathLength : dto.MaxPathLength,
NotBefore = Coalesce(cfg != null ? cfg.NotBefore : null, dto.NotBefore),
NotAfter = Coalesce(cfg != null ? cfg.NotAfter : null, dto.NotAfter),
SerialNumber = Coalesce(cfg != null ? cfg.SerialNumber : null, dto.SerialNumber),
ParentCaId = Coalesce(cfg != null ? cfg.ParentCaId : null, dto.ParentCaId),
ActiveCaCertId = Coalesce(cfg != null ? cfg.ActiveCaCertId : null, dto.ActiveCaCertId),
CreatedAtUtc = ParseTimestamp(dto.CreatedAt),
UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt)
};
}
private static string Coalesce(string primary, string fallback)
{
return !string.IsNullOrEmpty(primary) ? primary : fallback;
}
public static InfisicalCertificateAuthority[] MapMany(IEnumerable<InfisicalInternalCaResponseDto> items, string fallbackProjectId)
{
if (items == null)
@@ -0,0 +1,338 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Pki
{
internal static class InfisicalCertificateRequestHelpers
{
public static InfisicalCsrSubject MergeSubject(IDictionary subject, string commonName, string country, string state, string locality, string organization, string organizationalUnit, string emailAddress)
{
InfisicalCsrSubject result = new InfisicalCsrSubject();
if (subject != null)
{
result.CommonName = ReadString(subject, "CN", "CommonName");
result.Country = ReadString(subject, "C", "Country");
result.State = ReadString(subject, "ST", "S", "State");
result.Locality = ReadString(subject, "L", "Locality");
result.Organization = ReadString(subject, "O", "Organization");
result.OrganizationalUnit = ReadString(subject, "OU", "OrganizationalUnit");
result.EmailAddress = ReadString(subject, "E", "EMAIL", "EmailAddress");
}
if (!string.IsNullOrEmpty(commonName)) { result.CommonName = commonName; }
if (!string.IsNullOrEmpty(country)) { result.Country = country; }
if (!string.IsNullOrEmpty(state)) { result.State = state; }
if (!string.IsNullOrEmpty(locality)) { result.Locality = locality; }
if (!string.IsNullOrEmpty(organization)) { result.Organization = organization; }
if (!string.IsNullOrEmpty(organizationalUnit)) { result.OrganizationalUnit = organizationalUnit; }
if (!string.IsNullOrEmpty(emailAddress)) { result.EmailAddress = emailAddress; }
return result;
}
public static string ResolveLocalFqdn()
{
try
{
string host = System.Net.Dns.GetHostName();
string domain = null;
try { domain = System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties().DomainName; }
catch { domain = null; }
if (!string.IsNullOrEmpty(domain) && !host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase))
{
return string.Concat(host, ".", domain);
}
return host;
}
catch
{
return null;
}
}
public static void InstallToStore(X509Certificate2 cert, StoreName storeName, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
{
X509Store store = new X509Store(storeName, storeLocation);
try
{
store.Open(OpenFlags.ReadWrite);
X509Certificate2Collection existing = store.Certificates.Find(X509FindType.FindByThumbprint, cert.Thumbprint, false);
string target = string.Concat(storeLocation.ToString(), @"\", storeName.ToString(), " [", cert.Thumbprint, "]");
if (existing.Count > 0)
{
if (!force)
{
logger.Information(component, string.Concat("Certificate already present in ", target, "; no action taken."));
return;
}
store.RemoveRange(existing);
}
store.Add(cert);
logger.Information(component, string.Concat("Installed certificate to ", target, "."));
}
finally
{
store.Close();
}
}
public static void InstallChain(InfisicalSignedCertificate signed, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
{
List<X509Certificate2> chainCerts = CollectChainCertificates(signed);
InstallChain(chainCerts, storeLocation, force, logger, component);
}
public static void InstallChain(IEnumerable<X509Certificate2> chainCerts, StoreLocation storeLocation, bool force, IInfisicalLogger logger, string component)
{
if (chainCerts == null) { return; }
foreach (X509Certificate2 chainCert in chainCerts)
{
if (chainCert == null) { continue; }
StoreName targetStore = GetChainCertificateTargetStore(chainCert);
InstallToStore(chainCert, targetStore, storeLocation, force, logger, component);
}
}
public static StoreName GetChainCertificateTargetStore(X509Certificate2 cert)
{
return IsSelfSigned(cert) ? StoreName.Root : StoreName.CertificateAuthority;
}
public static X509KeyStorageFlags ResolveKeyStorageFlags(InfisicalPrivateKeyProtection protection, bool persistKey, bool machineKey)
{
X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet;
switch (protection)
{
case InfisicalPrivateKeyProtection.Exportable:
flags |= X509KeyStorageFlags.Exportable;
break;
case InfisicalPrivateKeyProtection.Ephemeral:
const int ephemeralValue = 32;
if (Enum.GetName(typeof(X509KeyStorageFlags), ephemeralValue) == null)
{
throw new PlatformNotSupportedException("InfisicalPrivateKeyProtection.Ephemeral requires .NET Core 3.0 or later (PowerShell 7+). Use LocalOnly or NonExportable on Windows PowerShell 5.1.");
}
flags |= (X509KeyStorageFlags)ephemeralValue;
break;
}
if (machineKey) { flags |= X509KeyStorageFlags.MachineKeySet; }
if (persistKey) { flags |= X509KeyStorageFlags.PersistKeySet; }
return flags;
}
public static bool ShouldScrubPrivateKeyPem(InfisicalPrivateKeyProtection protection, bool hasExplicitPrivateKeyPath)
{
if (hasExplicitPrivateKeyPath) { return true; }
return protection == InfisicalPrivateKeyProtection.NonExportable
|| protection == InfisicalPrivateKeyProtection.Ephemeral;
}
public static void WritePrivateKeyPem(string privateKeyPem, string path)
{
if (string.IsNullOrEmpty(privateKeyPem)) { throw new ArgumentException("PrivateKeyPem is empty.", nameof(privateKeyPem)); }
if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Path is required.", nameof(path)); }
string fullPath = System.IO.Path.GetFullPath(path);
string directory = System.IO.Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !System.IO.Directory.Exists(directory))
{
System.IO.Directory.CreateDirectory(directory);
}
System.IO.File.WriteAllText(fullPath, privateKeyPem);
}
public static InfisicalCertificateResult BuildResultFromExistingLocal(X509Certificate2 leaf)
{
return BuildResultFromExistingLocal(leaf, null);
}
public static InfisicalCertificateResult BuildResultFromExistingLocal(X509Certificate2 leaf, InfisicalCertificateBundle fallbackBundle)
{
if (leaf == null) { throw new ArgumentNullException(nameof(leaf)); }
InfisicalCertificateResult result = new InfisicalCertificateResult
{
Leaf = leaf,
SerialNumber = leaf.SerialNumber,
CertificatePem = ExportCertificateToPem(leaf)
};
List<X509Certificate2> chainElements = BuildLocalChain(leaf);
if (fallbackBundle != null && !string.IsNullOrEmpty(fallbackBundle.CertificateChainPem))
{
List<X509Certificate2> bundleChain = PemCertificateBuilder.ReadCertificateChain(fallbackBundle.CertificateChainPem);
HashSet<string> seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (X509Certificate2 c in chainElements) { if (c != null) { seen.Add(c.Thumbprint); } }
foreach (X509Certificate2 c in bundleChain)
{
if (c == null) { continue; }
if (seen.Add(c.Thumbprint)) { chainElements.Add(c); }
}
}
List<X509Certificate2> intermediates = new List<X509Certificate2>();
X509Certificate2 root = null;
foreach (X509Certificate2 cert in chainElements)
{
if (string.Equals(cert.Thumbprint, leaf.Thumbprint, StringComparison.OrdinalIgnoreCase)) { continue; }
if (IsSelfSigned(cert)) { if (root == null) { root = cert; } }
else { intermediates.Add(cert); }
}
result.Intermediates = intermediates.ToArray();
result.Root = root;
List<X509Certificate2> ordered = new List<X509Certificate2> { leaf };
ordered.AddRange(intermediates);
if (root != null) { ordered.Add(root); }
result.Chain = ordered.ToArray();
if (intermediates.Count > 0 || root != null)
{
StringBuilder sb = new StringBuilder();
foreach (X509Certificate2 c in intermediates) { sb.Append(ExportCertificateToPem(c)); }
if (root != null) { sb.Append(ExportCertificateToPem(root)); }
result.CertificateChainPem = sb.ToString();
}
return result;
}
public static InfisicalCertificateResult BuildResult(X509Certificate2 leaf, InfisicalSignedCertificate signed)
{
InfisicalCertificateResult result = new InfisicalCertificateResult { Leaf = leaf };
if (signed != null)
{
result.SerialNumber = signed.SerialNumber;
result.CertificatePem = signed.CertificatePem;
result.CertificateChainPem = signed.CertificateChainPem;
result.PrivateKeyPem = signed.PrivateKeyPem;
}
List<X509Certificate2> chainCerts = signed != null ? CollectChainCertificates(signed) : new List<X509Certificate2>();
List<X509Certificate2> intermediates = new List<X509Certificate2>();
X509Certificate2 root = null;
foreach (X509Certificate2 cert in chainCerts)
{
if (IsSelfSigned(cert)) { if (root == null) { root = cert; } }
else { intermediates.Add(cert); }
}
result.Intermediates = intermediates.ToArray();
result.Root = root;
List<X509Certificate2> ordered = new List<X509Certificate2>();
if (leaf != null) { ordered.Add(leaf); }
ordered.AddRange(intermediates);
if (root != null) { ordered.Add(root); }
result.Chain = ordered.ToArray();
return result;
}
private static List<X509Certificate2> CollectChainCertificates(InfisicalSignedCertificate signed)
{
List<X509Certificate2> chainCerts = PemCertificateBuilder.ReadCertificateChain(signed.CertificateChainPem);
if (!string.IsNullOrEmpty(signed.IssuingCaCertificatePem))
{
foreach (X509Certificate2 issuing in PemCertificateBuilder.ReadCertificateChain(signed.IssuingCaCertificatePem))
{
chainCerts.Add(issuing);
}
}
HashSet<string> seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<X509Certificate2> deduped = new List<X509Certificate2>();
foreach (X509Certificate2 cert in chainCerts)
{
if (cert == null) { continue; }
if (seen.Add(cert.Thumbprint)) { deduped.Add(cert); }
}
return deduped;
}
private static bool IsSelfSigned(X509Certificate2 cert)
{
if (cert == null) { return false; }
return string.Equals(cert.Subject, cert.Issuer, StringComparison.OrdinalIgnoreCase);
}
private static List<X509Certificate2> BuildLocalChain(X509Certificate2 leaf)
{
List<X509Certificate2> result = new List<X509Certificate2>();
using (X509Chain chain = new X509Chain())
{
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags =
X509VerificationFlags.IgnoreNotTimeValid |
X509VerificationFlags.IgnoreNotTimeNested |
X509VerificationFlags.IgnoreInvalidName |
X509VerificationFlags.IgnoreInvalidPolicy |
X509VerificationFlags.IgnoreEndRevocationUnknown |
X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown |
X509VerificationFlags.IgnoreRootRevocationUnknown |
X509VerificationFlags.IgnoreCtlNotTimeValid |
X509VerificationFlags.IgnoreCtlSignerRevocationUnknown |
X509VerificationFlags.IgnoreInvalidBasicConstraints |
X509VerificationFlags.IgnoreWrongUsage;
try { chain.Build(leaf); }
catch { return result; }
foreach (X509ChainElement element in chain.ChainElements)
{
if (element != null && element.Certificate != null)
{
result.Add(new X509Certificate2(element.Certificate.RawData));
}
}
}
return result;
}
private static string ExportCertificateToPem(X509Certificate2 cert)
{
byte[] der = cert.Export(X509ContentType.Cert);
StringBuilder sb = new StringBuilder();
sb.AppendLine("-----BEGIN CERTIFICATE-----");
sb.AppendLine(Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks));
sb.AppendLine("-----END CERTIFICATE-----");
return sb.ToString();
}
private static string ReadString(IDictionary source, params string[] keys)
{
foreach (string key in keys)
{
if (source.Contains(key))
{
object value = source[key];
if (value != null)
{
string text = value.ToString();
if (!string.IsNullOrEmpty(text))
{
return text;
}
}
}
}
return null;
}
}
}
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Pkcs;
using Org.BouncyCastle.Asn1.Sec;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using BcAttribute = Org.BouncyCastle.Asn1.Cms.Attribute;
namespace PSInfisicalAPI.Pki
{
public enum InfisicalKeyAlgorithm
{
Rsa = 0,
Ecdsa = 1,
Ed25519 = 2
}
public enum InfisicalEcCurve
{
P256 = 0,
P384 = 1
}
public sealed class InfisicalCsrSubject
{
public string CommonName { get; set; }
public string Country { get; set; }
public string State { get; set; }
public string Locality { get; set; }
public string Organization { get; set; }
public string OrganizationalUnit { get; set; }
public string EmailAddress { get; set; }
}
public sealed class InfisicalCsrOptions
{
public InfisicalKeyAlgorithm KeyAlgorithm { get; set; } = InfisicalKeyAlgorithm.Rsa;
public int RsaKeySize { get; set; } = 2048;
public InfisicalEcCurve EcCurve { get; set; } = InfisicalEcCurve.P256;
}
public sealed class InfisicalCsrResult
{
public string CsrPem { get; set; }
public string PrivateKeyPem { get; set; }
}
public static class InfisicalCsrBuilder
{
public static InfisicalCsrResult Build(InfisicalCsrSubject subject, IEnumerable<string> dnsNames, IEnumerable<string> ipAddresses, InfisicalCsrOptions options)
{
if (subject == null) { throw new ArgumentNullException(nameof(subject)); }
if (string.IsNullOrEmpty(subject.CommonName)) { throw new ArgumentException("Subject.CommonName is required.", nameof(subject)); }
if (options == null) { options = new InfisicalCsrOptions(); }
SecureRandom random = new SecureRandom();
AsymmetricCipherKeyPair keyPair = GenerateKeyPair(options, random);
string signatureAlgorithm = ResolveSignatureAlgorithm(options);
X509Name x509Name = BuildX509Name(subject);
Asn1Set attributes = BuildSanAttributes(dnsNames, ipAddresses);
Pkcs10CertificationRequest pkcs10 = new Pkcs10CertificationRequest(signatureAlgorithm, x509Name, keyPair.Public, attributes, keyPair.Private);
return new InfisicalCsrResult
{
CsrPem = WritePem(pkcs10),
PrivateKeyPem = WritePem(keyPair.Private)
};
}
private static AsymmetricCipherKeyPair GenerateKeyPair(InfisicalCsrOptions options, SecureRandom random)
{
switch (options.KeyAlgorithm)
{
case InfisicalKeyAlgorithm.Rsa:
{
int keySize = options.RsaKeySize;
if (keySize != 2048 && keySize != 3072 && keySize != 4096)
{
throw new ArgumentException("RsaKeySize must be 2048, 3072, or 4096.", nameof(options));
}
RsaKeyPairGenerator generator = new RsaKeyPairGenerator();
generator.Init(new KeyGenerationParameters(random, keySize));
return generator.GenerateKeyPair();
}
case InfisicalKeyAlgorithm.Ecdsa:
{
DerObjectIdentifier curveOid = options.EcCurve == InfisicalEcCurve.P384
? SecObjectIdentifiers.SecP384r1
: SecObjectIdentifiers.SecP256r1;
ECKeyPairGenerator generator = new ECKeyPairGenerator("ECDSA");
generator.Init(new ECKeyGenerationParameters(curveOid, random));
return generator.GenerateKeyPair();
}
case InfisicalKeyAlgorithm.Ed25519:
{
Ed25519KeyPairGenerator generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(random));
return generator.GenerateKeyPair();
}
default:
throw new ArgumentOutOfRangeException(nameof(options), options.KeyAlgorithm, "Unsupported KeyAlgorithm.");
}
}
private static string ResolveSignatureAlgorithm(InfisicalCsrOptions options)
{
switch (options.KeyAlgorithm)
{
case InfisicalKeyAlgorithm.Rsa:
return "SHA256WITHRSA";
case InfisicalKeyAlgorithm.Ecdsa:
return options.EcCurve == InfisicalEcCurve.P384 ? "SHA384WITHECDSA" : "SHA256WITHECDSA";
case InfisicalKeyAlgorithm.Ed25519:
return "Ed25519";
default:
throw new ArgumentOutOfRangeException(nameof(options), options.KeyAlgorithm, "Unsupported KeyAlgorithm.");
}
}
private static X509Name BuildX509Name(InfisicalCsrSubject subject)
{
List<DerObjectIdentifier> order = new List<DerObjectIdentifier>();
Dictionary<DerObjectIdentifier, string> values = new Dictionary<DerObjectIdentifier, string>();
AppendComponent(order, values, X509Name.C, subject.Country);
AppendComponent(order, values, X509Name.ST, subject.State);
AppendComponent(order, values, X509Name.L, subject.Locality);
AppendComponent(order, values, X509Name.O, subject.Organization);
AppendComponent(order, values, X509Name.OU, subject.OrganizationalUnit);
AppendComponent(order, values, X509Name.CN, subject.CommonName);
AppendComponent(order, values, X509Name.EmailAddress, subject.EmailAddress);
return new X509Name(order, values);
}
private static void AppendComponent(List<DerObjectIdentifier> order, Dictionary<DerObjectIdentifier, string> values, DerObjectIdentifier oid, string value)
{
if (string.IsNullOrEmpty(value)) { return; }
order.Add(oid);
values[oid] = value;
}
private static Asn1Set BuildSanAttributes(IEnumerable<string> dnsNames, IEnumerable<string> ipAddresses)
{
List<GeneralName> generalNames = new List<GeneralName>();
if (dnsNames != null)
{
foreach (string dns in dnsNames)
{
if (string.IsNullOrEmpty(dns)) { continue; }
generalNames.Add(new GeneralName(GeneralName.DnsName, dns));
}
}
if (ipAddresses != null)
{
foreach (string ip in ipAddresses)
{
if (string.IsNullOrEmpty(ip)) { continue; }
IPAddress parsed;
if (!IPAddress.TryParse(ip, out parsed)) { continue; }
generalNames.Add(new GeneralName(GeneralName.IPAddress, ip));
}
}
if (generalNames.Count == 0) { return null; }
GeneralNames sanValue = new GeneralNames(generalNames.ToArray());
X509Extensions extensions = new X509Extensions(
new Dictionary<DerObjectIdentifier, X509Extension>
{
{ X509Extensions.SubjectAlternativeName, new X509Extension(false, new DerOctetString(sanValue)) }
});
BcAttribute extensionRequest = new BcAttribute(PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extensions));
return new DerSet(extensionRequest);
}
private static string WritePem(object obj)
{
using (StringWriter sw = new StringWriter())
{
PemWriter pemWriter = new PemWriter(sw);
pemWriter.WriteObject(obj);
pemWriter.Writer.Flush();
return sw.ToString();
}
}
}
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace PSInfisicalAPI.Pki
{
internal static class InfisicalLocalCertificateLookup
{
public static X509Certificate2 FindMatch(StoreName storeName, StoreLocation storeLocation, string commonName, IEnumerable<string> candidateSerialNumbers)
{
HashSet<string> serialSet = NormalizeSerials(candidateSerialNumbers);
string subjectFilter = !string.IsNullOrEmpty(commonName) ? string.Concat("CN=", commonName) : null;
X509Store store = new X509Store(storeName, storeLocation);
try
{
store.Open(OpenFlags.ReadOnly);
X509Certificate2 bestMatch = null;
foreach (X509Certificate2 candidate in store.Certificates)
{
if (subjectFilter != null && candidate.Subject.IndexOf(subjectFilter, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
if (serialSet.Count > 0)
{
string normalizedSerial = NormalizeSerial(candidate.SerialNumber);
if (!serialSet.Contains(normalizedSerial))
{
continue;
}
}
if (bestMatch == null || candidate.NotAfter > bestMatch.NotAfter)
{
bestMatch = candidate;
}
}
return bestMatch;
}
finally
{
store.Close();
}
}
public static bool IsRenewable(X509Certificate2 cert, int renewalThresholdDays)
{
if (cert == null) { return true; }
DateTime threshold = DateTime.UtcNow.AddDays(renewalThresholdDays);
return cert.NotAfter.ToUniversalTime() <= threshold;
}
private static HashSet<string> NormalizeSerials(IEnumerable<string> serials)
{
HashSet<string> set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (serials == null) { return set; }
foreach (string serial in serials)
{
string normalized = NormalizeSerial(serial);
if (!string.IsNullOrEmpty(normalized))
{
set.Add(normalized);
}
}
return set;
}
private static string NormalizeSerial(string value)
{
if (string.IsNullOrEmpty(value)) { return null; }
string trimmed = value.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring(2);
}
return trimmed.Replace(":", string.Empty).Replace(" ", string.Empty).TrimStart('0');
}
}
}
+167 -15
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json.Linq;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
@@ -8,6 +9,7 @@ using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Serialization;
using System.Linq;
namespace PSInfisicalAPI.Pki
{
@@ -30,23 +32,23 @@ namespace PSInfisicalAPI.Pki
public InfisicalCertificateAuthority[] ListInternalCertificateAuthorities(InfisicalConnection connection, string projectId)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId);
List<KeyValuePair<string, string>> query = null;
if (!string.IsNullOrEmpty(resolvedProjectId))
if (!string.IsNullOrEmpty(projectId))
{
query = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("projectId", resolvedProjectId) };
query = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("projectId", projectId) };
}
try
{
_logger.Information(Component, "Attempting to list Infisical internal certificate authorities. Please Wait...");
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.ListInternalCertificateAuthorities, "ListInternalCertificateAuthorities", null, query, null);
InfisicalInternalCaListResponseDto dto = _serializer.Deserialize<InfisicalInternalCaListResponseDto>(response.Body);
string body = response.Body;
response.Clear();
List<InfisicalInternalCaResponseDto> source = dto != null ? (dto.CertificateAuthorities ?? dto.Cas) : null;
InfisicalCertificateAuthority[] mapped = InfisicalCaMapper.MapMany(source, resolvedProjectId);
List<InfisicalInternalCaResponseDto> source = ParseCaListBody(body);
string fallbackProjectId = !string.IsNullOrEmpty(projectId) ? projectId : connection.ProjectId;
InfisicalCertificateAuthority[] mapped = InfisicalCaMapper.MapMany(source, fallbackProjectId);
_logger.Information(Component, "Infisical internal certificate authority list retrieval was successful.");
return mapped;
}
@@ -63,21 +65,22 @@ namespace PSInfisicalAPI.Pki
if (string.IsNullOrEmpty(caId)) { throw new InfisicalConfigurationException("CaId is required."); }
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "caId", caId } };
List<KeyValuePair<string, string>> query = null;
if (!string.IsNullOrEmpty(projectId))
{
query = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("projectId", projectId) };
}
try
{
_logger.Information(Component, string.Concat("Attempting to retrieve Infisical internal certificate authority '", caId, "'. Please Wait..."));
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.RetrieveInternalCertificateAuthority, "RetrieveInternalCertificateAuthority", pathParameters, null, null);
InfisicalInternalCaSingleResponseDto dto = _serializer.Deserialize<InfisicalInternalCaSingleResponseDto>(response.Body);
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.RetrieveInternalCertificateAuthority, "RetrieveInternalCertificateAuthority", pathParameters, query, null);
string body = response.Body;
response.Clear();
InfisicalInternalCaResponseDto inner = dto != null ? (dto.CertificateAuthority ?? dto.Ca) : null;
if (inner == null)
{
inner = _serializer.Deserialize<InfisicalInternalCaResponseDto>(response.Body);
}
InfisicalCertificateAuthority mapped = InfisicalCaMapper.Map(inner, FirstNonEmpty(projectId, connection.ProjectId));
InfisicalInternalCaResponseDto inner = ParseCaSingleBody(body);
string fallbackProjectId = !string.IsNullOrEmpty(projectId) ? projectId : connection.ProjectId;
InfisicalCertificateAuthority mapped = InfisicalCaMapper.Map(inner, fallbackProjectId);
_logger.Information(Component, "Infisical internal certificate authority retrieval was successful.");
return mapped;
}
@@ -88,6 +91,68 @@ namespace PSInfisicalAPI.Pki
}
}
public InfisicalCertificate RetrieveCertificate(InfisicalConnection connection, string identifier)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (string.IsNullOrEmpty(identifier)) { throw new InfisicalConfigurationException("Identifier (serial number or id) is required."); }
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "serialNumber", identifier } };
try
{
_logger.Information(Component, string.Concat("Attempting to retrieve Infisical certificate '", identifier, "'. Please Wait..."));
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.RetrieveCertificate, "RetrieveCertificate", pathParameters, null, null);
string body = response.Body;
response.Clear();
InfisicalCertificateResponseDto inner = ParseCertificateSingleBody(body);
InfisicalCertificate mapped = InfisicalCertificateMapper.Map(inner, connection.ProjectId);
_logger.Information(Component, "Infisical certificate retrieval was successful.");
return mapped;
}
catch (Exception)
{
_logger.Error(Component, "Infisical certificate retrieval failed.");
throw;
}
}
private List<InfisicalInternalCaResponseDto> ParseCaListBody(string body)
{
if (string.IsNullOrEmpty(body)) { return null; }
JToken token = JToken.Parse(body);
if (token.Type == JTokenType.Array)
{
return token.ToObject<List<InfisicalInternalCaResponseDto>>();
}
InfisicalInternalCaListResponseDto wrapper = token.ToObject<InfisicalInternalCaListResponseDto>();
return wrapper != null ? (wrapper.CertificateAuthorities ?? wrapper.Cas) : null;
}
private InfisicalInternalCaResponseDto ParseCaSingleBody(string body)
{
if (string.IsNullOrEmpty(body)) { return null; }
JToken token = JToken.Parse(body);
if (token.Type != JTokenType.Object) { return null; }
JObject obj = (JObject)token;
if (obj["certificateAuthority"] is JObject ca1) { return ca1.ToObject<InfisicalInternalCaResponseDto>(); }
if (obj["ca"] is JObject ca2) { return ca2.ToObject<InfisicalInternalCaResponseDto>(); }
return obj.ToObject<InfisicalInternalCaResponseDto>();
}
private InfisicalCertificateResponseDto ParseCertificateSingleBody(string body)
{
if (string.IsNullOrEmpty(body)) { return null; }
JToken token = JToken.Parse(body);
if (token.Type != JTokenType.Object) { return null; }
JObject obj = (JObject)token;
if (obj["certificate"] is JObject cert) { return cert.ToObject<InfisicalCertificateResponseDto>(); }
return obj.ToObject<InfisicalCertificateResponseDto>();
}
public InfisicalCertificateSearchResult SearchCertificates(InfisicalConnection connection, InfisicalCertificateSearchQuery query)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
@@ -118,6 +183,93 @@ namespace PSInfisicalAPI.Pki
}
}
public InfisicalSignedCertificate SignCertificateBySubscriber(InfisicalConnection connection, string subscriberName, string projectId, string csrPem)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (string.IsNullOrEmpty(subscriberName)) { throw new InfisicalConfigurationException("SubscriberName is required."); }
if (string.IsNullOrEmpty(csrPem)) { throw new InfisicalConfigurationException("CSR is required."); }
string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId);
if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); }
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "subscriberName", subscriberName } };
InfisicalSignCertificateBySubscriberRequestDto request = new InfisicalSignCertificateBySubscriberRequestDto
{
ProjectId = resolvedProjectId,
Csr = csrPem
};
string body = _serializer.Serialize(request);
try
{
_logger.Information(Component, string.Concat("Attempting to sign certificate via subscriber '", subscriberName, "'. Please Wait..."));
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.SignCertificateBySubscriber, "SignCertificateBySubscriber", pathParameters, null, body);
InfisicalSignCertificateResponseDto dto = _serializer.Deserialize<InfisicalSignCertificateResponseDto>(response.Body);
response.Clear();
InfisicalSignedCertificate signed = MapSigned(dto);
_logger.Information(Component, "Infisical certificate signing (subscriber) was successful.");
return signed;
}
catch (Exception)
{
_logger.Error(Component, "Infisical certificate signing (subscriber) failed.");
throw;
}
}
public InfisicalSignedCertificate SignCertificateByCa(InfisicalConnection connection, string caId, string csrPem, string commonName, string altNames, string ttl, string notBefore, string notAfter, string friendlyName, string pkiCollectionId, IEnumerable<string> keyUsages, IEnumerable<string> extendedKeyUsages)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (string.IsNullOrEmpty(caId)) { throw new InfisicalConfigurationException("CaId is required."); }
if (string.IsNullOrEmpty(csrPem)) { throw new InfisicalConfigurationException("CSR is required."); }
if (string.IsNullOrEmpty(ttl) && string.IsNullOrEmpty(notAfter)) { throw new InfisicalConfigurationException("Either Ttl or NotAfter must be provided."); }
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "caId", caId } };
InfisicalSignCertificateByCaRequestDto request = new InfisicalSignCertificateByCaRequestDto
{
Csr = csrPem,
CommonName = commonName,
AltNames = altNames,
Ttl = ttl,
NotBefore = notBefore,
NotAfter = notAfter,
FriendlyName = friendlyName,
PkiCollectionId = pkiCollectionId,
KeyUsages = keyUsages != null ? keyUsages.ToList() : null,
ExtendedKeyUsages = extendedKeyUsages != null ? extendedKeyUsages.ToList() : null
};
string body = _serializer.Serialize(request);
try
{
_logger.Information(Component, string.Concat("Attempting to sign certificate via CA '", caId, "'. Please Wait..."));
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.SignCertificateByCa, "SignCertificateByCa", pathParameters, null, body);
InfisicalSignCertificateResponseDto dto = _serializer.Deserialize<InfisicalSignCertificateResponseDto>(response.Body);
response.Clear();
InfisicalSignedCertificate signed = MapSigned(dto);
_logger.Information(Component, "Infisical certificate signing (CA) was successful.");
return signed;
}
catch (Exception)
{
_logger.Error(Component, "Infisical certificate signing (CA) failed.");
throw;
}
}
private static InfisicalSignedCertificate MapSigned(InfisicalSignCertificateResponseDto dto)
{
if (dto == null) { return null; }
return new InfisicalSignedCertificate
{
SerialNumber = dto.SerialNumber,
CertificatePem = dto.Certificate,
CertificateChainPem = dto.CertificateChain,
IssuingCaCertificatePem = dto.IssuingCaCertificate
};
}
public InfisicalCertificateBundle GetCertificateBundle(InfisicalConnection connection, string serialNumber)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
@@ -0,0 +1,10 @@
namespace PSInfisicalAPI.Pki
{
public enum InfisicalPrivateKeyProtection
{
Exportable = 0,
LocalOnly = 1,
NonExportable = 2,
Ephemeral = 3
}
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace PSInfisicalAPI.Pki
{
internal sealed class InfisicalSignCertificateBySubscriberRequestDto
{
[JsonProperty("projectId")] public string ProjectId { get; set; }
[JsonProperty("csr")] public string Csr { get; set; }
}
internal sealed class InfisicalSignCertificateByCaRequestDto
{
[JsonProperty("csr")] public string Csr { get; set; }
[JsonProperty("commonName", NullValueHandling = NullValueHandling.Ignore)] public string CommonName { get; set; }
[JsonProperty("altNames", NullValueHandling = NullValueHandling.Ignore)] public string AltNames { get; set; }
[JsonProperty("ttl", NullValueHandling = NullValueHandling.Ignore)] public string Ttl { get; set; }
[JsonProperty("notBefore", NullValueHandling = NullValueHandling.Ignore)] public string NotBefore { get; set; }
[JsonProperty("notAfter", NullValueHandling = NullValueHandling.Ignore)] public string NotAfter { get; set; }
[JsonProperty("friendlyName", NullValueHandling = NullValueHandling.Ignore)] public string FriendlyName { get; set; }
[JsonProperty("pkiCollectionId", NullValueHandling = NullValueHandling.Ignore)] public string PkiCollectionId { get; set; }
[JsonProperty("keyUsages", NullValueHandling = NullValueHandling.Ignore)] public List<string> KeyUsages { get; set; }
[JsonProperty("extendedKeyUsages", NullValueHandling = NullValueHandling.Ignore)] public List<string> ExtendedKeyUsages { get; set; }
}
internal sealed class InfisicalSignCertificateResponseDto
{
[JsonProperty("certificate")] public string Certificate { get; set; }
[JsonProperty("certificateChain")] public string CertificateChain { get; set; }
[JsonProperty("issuingCaCertificate")] public string IssuingCaCertificate { get; set; }
[JsonProperty("serialNumber")] public string SerialNumber { get; set; }
}
}