diff --git a/.gitea/workflows/publish-psgallery.yml b/.gitea/workflows/publish-psgallery.yml index 9db7448..435c250 100644 --- a/.gitea/workflows/publish-psgallery.yml +++ b/.gitea/workflows/publish-psgallery.yml @@ -41,7 +41,7 @@ jobs: Write-Host "Manifest OK: $($manifest.Name) $($manifest.Version)" - name: Upload module artifact - uses: actions/upload-artifact@v4 + uses: christopherhx/gitea-upload-artifact@v4 with: name: PSInfisicalAPI-module path: Module/PSInfisicalAPI @@ -71,7 +71,7 @@ jobs: Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')) - name: Download module artifact - uses: actions/download-artifact@v4 + uses: christopherhx/gitea-download-artifact@v4 with: name: PSInfisicalAPI-module path: Module/PSInfisicalAPI @@ -213,7 +213,7 @@ jobs: Write-Host ("pwsh: " + (pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')) - name: Download module artifact - uses: actions/download-artifact@v4 + uses: christopherhx/gitea-download-artifact@v4 with: name: PSInfisicalAPI-module path: Module/PSInfisicalAPI diff --git a/CHANGELOG.md b/CHANGELOG.md index 080548b..da75758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,45 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +- **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 + +- Build produced from commit 2cbd5c2008f5. + +## Unreleased (carried forward) + +- **M10 polish — formatting, type metadata, and PKI route aliases**: + - Added default table views and `DefaultDisplayPropertySet` entries for `InfisicalCertificateAuthority`, `InfisicalCertificate`, and `InfisicalCertificateBundle` in the module `Format.ps1xml` / `Types.ps1xml`. + - Realigned PKI endpoint registry to current Infisical paths: `ListInternalCertificateAuthorities` and `RetrieveInternalCertificateAuthority` now use `/api/v1/cert-manager/ca/internal[/{caId}]` as primary, with legacy `/api/v1/pki/ca/internal[/{caId}]` retained as a fallback alias. `GetCertificateBundle` and `RetrieveCertificate` similarly carry `cert-manager` fallback aliases. + - `InfisicalApiInvoker.InvokeWithCandidateFallback` walks the candidate list and falls back on `404`/`405`, used by `InfisicalPkiClient` so older self-hosted Infisical instances are tolerated transparently. + +## 2026.06.04.0114 + +- Build produced from commit 2cbd5c2008f5. + +## Unreleased (carried forward) + +- **M10 — PKI Internal CAs, Certificates & Windows Store integration**: + - **`Get-InfisicalCertificateAuthority`** lists internal certificate authorities for the current project, or returns a single CA with `-CaId`. + - **`Search-InfisicalCertificate`** wraps `POST /api/v1/projects/{projectId}/certificates/search` with rich filters (`-CommonName`, `-FriendlyName`, `-Search`, `-Status`, `-CaId`, `-ProfileId`, `-ApplicationId`, `-EnrollmentType`, `-KeyAlgorithm`, `-SignatureAlgorithm`, `-Source`, `-NotAfterFrom/To`, `-NotBeforeFrom/To`, `-SortBy/-SortOrder`, `-Limit/-Offset`). Auto-paginates unless `-NoAutoPage` is set. + - **`ConvertTo-InfisicalCertificate`** accepts an `InfisicalCertificate`, `InfisicalCertificateBundle`, or `-SerialNumber`, fetches the bundle endpoint when needed, and emits a `System.Security.Cryptography.X509Certificates.X509Certificate2` with the private key attached. `-NoPrivateKey` skips key parsing; `-IncludeChain` additionally emits intermediates; `-KeyStorageFlags` controls import behavior. + - **`Install-InfisicalCertificate`** / **`Uninstall-InfisicalCertificate`** perform idempotent installs/removes against a Windows `X509Store` (`-StoreName`, `-StoreLocation`, defaults `My`/`CurrentUser`), matching by thumbprint. Install is a no-op when the thumbprint is already present unless `-Force` is supplied (which replaces the existing entry). Both honor `ShouldProcess` and accept pipeline input. + - **`Export-InfisicalCertificate`** writes PEM, PFX, or CER to disk via `-Format`, with `-Password` (SecureString) for PFX, `-IncludeChain` for full-chain PEM, `-NoPrivateKey` to omit the key, and `-Force` to overwrite. + - **BouncyCastle dependency**: Added `BouncyCastle.Cryptography` to bridge PEM/PKCS#8 parsing on .NET Standard 2.0 / Windows PowerShell 5.1 (where `X509Certificate2.CreateFromPem` and `RSA.ImportFromPem` are unavailable). The shared `PemCertificateBuilder` assembles cert + chain + key into an in-memory PKCS#12 blob and imports it back into `X509Certificate2`. The DLL ships in the published module bin directory. + - PKI endpoint registry entries for `ListInternalCertificateAuthorities` (`GET /api/v1/pki/ca/internal`), `RetrieveInternalCertificateAuthority` (`GET /api/v1/pki/ca/internal/{caId}`), `SearchCertificates` (`POST /api/v1/projects/{projectId}/certificates/search`), `RetrieveCertificate`, and `GetCertificateBundle` (`GET /api/v1/pki/certificates/{serialNumber}/bundle`). + ## 2026.06.04.0020 - Build produced from commit 211fbcf34dbb. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.04.0005 - Build produced from commit e0a6ef02df3e. -## Unreleased (carried forward) +## Unreleased (carried forward) - **Bulk v4 batch routes**: Endpoint registry now registers `POST|PATCH|DELETE /api/v4/secrets/batch` as the preferred candidates for `BulkCreateSecret`/`BulkUpdateSecret`/`BulkDeleteSecret`; the existing v3 raw routes (`/api/v3/secrets/batch/raw`) remain as automatic fallback. Batch request DTOs serialize both `projectId` (required by v4) and `workspaceId` (accepted by v3) when populated. - **Strongly-typed bulk input**: `-Secrets` on `New-InfisicalSecret` and `Update-InfisicalSecret` is now `IDictionary[]` instead of `Hashtable[]`. `InfisicalBulkSecretConverter` accepts `IEnumerable>` and parses `TagIds` from a comma-separated string. Nested `Metadata`/`SecretMetadata` dictionaries are no longer accepted in the bulk hashtable surface (set `SecretMetadata` programmatically on `InfisicalBulkCreateSecretItem`/`InfisicalBulkUpdateSecretItem` if needed). @@ -24,7 +52,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## 2026.06.03.2207 - Build produced from commit 09c3d5c68bbc. -- **M9 — Bulk, Duplicate & Inheritance**: +- **M9 — Bulk, Duplicate & Inheritance**: - **Bulk parameter sets** added to `New-InfisicalSecret`, `Update-InfisicalSecret`, and `Remove-InfisicalSecret` accepting `-Secrets Hashtable[]`; client methods `CreateBatch`/`UpdateBatch`/`DeleteBatch` wrap `POST|PATCH|DELETE /api/v3/secrets/batch/raw`. - **`Copy-InfisicalSecret`** cmdlet added, wrapping `POST /api/v4/secrets/duplicate` with source/destination environment + path parameters and per-attribute copy toggles. - **Connection inheritance** centralized in `InfisicalCmdletBase` (`ResolveProjectId`/`ResolveEnvironment`/`ResolveSecretPath`/`ResolveApiVersion`/`ResolveOrganizationId`). Explicit parameters always win; missing values fall back to the active connection and emit a `-Verbose` line. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml index 9a28be2..34418a1 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml @@ -31,5 +31,63 @@ + + PSInfisicalAPI.Models.InfisicalCertificateAuthority + + PSInfisicalAPI.Models.InfisicalCertificateAuthority + + + + 28 + 32 + 10 + 10 + 14 + 22 + + + + + Name + CommonName + Type + Status + KeyAlgorithm + NotAfter + + + + + + + PSInfisicalAPI.Models.InfisicalCertificate + + PSInfisicalAPI.Models.InfisicalCertificate + + + + 32 + 24 + 10 + 20 + 22 + 6 + 18 + + + + + CommonName + FriendlyName + Status + SerialNumber + NotAfterUtc + HasPrivateKey + CaName + + + + + diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml b/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml index 41b2854..0f28596 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.Types.ps1xml @@ -46,4 +46,64 @@ + + PSInfisicalAPI.Models.InfisicalCertificateAuthority + + + PSStandardMembers + + + DefaultDisplayPropertySet + + Name + CommonName + Type + Status + KeyAlgorithm + NotAfter + Id + + + + + + + + PSInfisicalAPI.Models.InfisicalCertificate + + + PSStandardMembers + + + DefaultDisplayPropertySet + + CommonName + FriendlyName + Status + SerialNumber + NotAfterUtc + HasPrivateKey + CaName + + + + + + + + PSInfisicalAPI.Models.InfisicalCertificateBundle + + + PSStandardMembers + + + DefaultDisplayPropertySet + + SerialNumber + + + + + + diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 57b8442..e0de7f0 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.04.0020' + ModuleVersion = '2026.06.04.0123' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -39,7 +39,13 @@ 'Get-InfisicalTag', 'New-InfisicalTag', 'Update-InfisicalTag', - 'Remove-InfisicalTag' + 'Remove-InfisicalTag', + 'Get-InfisicalCertificateAuthority', + 'Search-InfisicalCertificate', + 'ConvertTo-InfisicalCertificate', + 'Install-InfisicalCertificate', + 'Uninstall-InfisicalCertificate', + 'Export-InfisicalCertificate' ) AliasesToExport = @() VariablesToExport = @() @@ -51,7 +57,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 = '211fbcf34dbb' + CommitHash = '2cbd5c2008f5' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/BouncyCastle.Cryptography.dll b/Module/PSInfisicalAPI/bin/BouncyCastle.Cryptography.dll new file mode 100644 index 0000000..4b3f9e0 Binary files /dev/null and b/Module/PSInfisicalAPI/bin/BouncyCastle.Cryptography.dll differ diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index d3eae94..e4308f8 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/build.ps1 b/build.ps1 index 6a0066d..ae27921 100644 --- a/build.ps1 +++ b/build.ps1 @@ -127,7 +127,13 @@ function Write-Manifest { 'Get-InfisicalTag', 'New-InfisicalTag', 'Update-InfisicalTag', - 'Remove-InfisicalTag' + 'Remove-InfisicalTag', + 'Get-InfisicalCertificateAuthority', + 'Search-InfisicalCertificate', + 'ConvertTo-InfisicalCertificate', + 'Install-InfisicalCertificate', + 'Uninstall-InfisicalCertificate', + 'Export-InfisicalCertificate' ) AliasesToExport = @() VariablesToExport = @() @@ -187,7 +193,7 @@ 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') +`$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" @@ -283,7 +289,7 @@ $publishArgs = @( Invoke-DotNet -Arguments $publishArgs Clear-Directory -Directory $ModuleBinDir -$desiredAssemblies = @('PSInfisicalAPI.dll','Newtonsoft.Json.dll','YamlDotNet.dll') +$desiredAssemblies = @('PSInfisicalAPI.dll','Newtonsoft.Json.dll','YamlDotNet.dll','BouncyCastle.Cryptography.dll') foreach ($assembly in $desiredAssemblies) { $source = [System.IO.FileInfo][System.IO.Path]::Combine($publishOutput.FullName, $assembly) if ($source.Exists) { diff --git a/src/PSInfisicalAPI.Tests/CertificateMapperTests.cs b/src/PSInfisicalAPI.Tests/CertificateMapperTests.cs new file mode 100644 index 0000000..a9653b3 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/CertificateMapperTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Reflection; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class CertificateMapperTests + { + private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly; + private static readonly Type CertMapperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMapper", true); + 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 BundleDtoType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateBundleResponseDto", true); + + private static InfisicalCertificate InvokeCertMap(object dto, string fallbackProjectId) + { + MethodInfo map = CertMapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalCertificate)map.Invoke(null, new object[] { dto, fallbackProjectId }); + } + + private static InfisicalCertificateAuthority InvokeCaMap(object dto, string fallbackProjectId) + { + MethodInfo map = CaMapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalCertificateAuthority)map.Invoke(null, new object[] { dto, fallbackProjectId }); + } + + private static InfisicalCertificateBundle InvokeBundleMap(object dto) + { + MethodInfo map = CertMapperType.GetMethod("MapBundle", BindingFlags.Public | BindingFlags.Static); + return (InfisicalCertificateBundle)map.Invoke(null, new object[] { dto }); + } + + [Fact] + public void CertificateMap_Null_Returns_Null() + { + Assert.Null(InvokeCertMap(null, "proj-x")); + } + + [Fact] + public void CertificateMap_Populates_Fields_And_Parses_Timestamps() + { + object dto = Activator.CreateInstance(CertDtoType); + CertDtoType.GetProperty("Id").SetValue(dto, "cert-1"); + CertDtoType.GetProperty("SerialNumber").SetValue(dto, "AABBCC"); + CertDtoType.GetProperty("CommonName").SetValue(dto, "example.com"); + CertDtoType.GetProperty("FriendlyName").SetValue(dto, "Example"); + CertDtoType.GetProperty("HasPrivateKey").SetValue(dto, true); + CertDtoType.GetProperty("NotAfter").SetValue(dto, "2030-01-02T03:04:05Z"); + + InfisicalCertificate mapped = InvokeCertMap(dto, "proj-fallback"); + Assert.Equal("cert-1", mapped.Id); + Assert.Equal("AABBCC", mapped.SerialNumber); + Assert.Equal("example.com", mapped.CommonName); + Assert.Equal("Example", mapped.FriendlyName); + Assert.True(mapped.HasPrivateKey); + Assert.Equal("proj-fallback", mapped.ProjectId); + Assert.True(mapped.NotAfterUtc.HasValue); + Assert.Equal(new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero), mapped.NotAfterUtc.Value); + } + + [Fact] + public void CertificateMap_Explicit_ProjectId_Wins_Over_Fallback() + { + object dto = Activator.CreateInstance(CertDtoType); + CertDtoType.GetProperty("Id").SetValue(dto, "cert-2"); + CertDtoType.GetProperty("ProjectId").SetValue(dto, "proj-real"); + + InfisicalCertificate mapped = InvokeCertMap(dto, "proj-fallback"); + Assert.Equal("proj-real", mapped.ProjectId); + } + + [Fact] + public void CaMap_Populates_Fields() + { + object dto = Activator.CreateInstance(CaDtoType); + CaDtoType.GetProperty("Id").SetValue(dto, "ca-1"); + CaDtoType.GetProperty("Name").SetValue(dto, "internal-root"); + CaDtoType.GetProperty("Type").SetValue(dto, "internal"); + CaDtoType.GetProperty("Status").SetValue(dto, "active"); + CaDtoType.GetProperty("CommonName").SetValue(dto, "Internal Root CA"); + + InfisicalCertificateAuthority mapped = InvokeCaMap(dto, "proj-fallback"); + Assert.Equal("ca-1", mapped.Id); + Assert.Equal("internal-root", mapped.Name); + Assert.Equal("internal", mapped.Type); + Assert.Equal("active", mapped.Status); + Assert.Equal("Internal Root CA", mapped.CommonName); + Assert.Equal("proj-fallback", mapped.ProjectId); + } + + [Fact] + public void BundleMap_Maps_All_Pem_Fields() + { + object dto = Activator.CreateInstance(BundleDtoType); + BundleDtoType.GetProperty("SerialNumber").SetValue(dto, "AABBCC"); + BundleDtoType.GetProperty("Certificate").SetValue(dto, "CERT-PEM"); + BundleDtoType.GetProperty("CertificateChain").SetValue(dto, "CHAIN-PEM"); + BundleDtoType.GetProperty("PrivateKey").SetValue(dto, "KEY-PEM"); + + InfisicalCertificateBundle mapped = InvokeBundleMap(dto); + Assert.Equal("AABBCC", mapped.SerialNumber); + Assert.Equal("CERT-PEM", mapped.CertificatePem); + Assert.Equal("CHAIN-PEM", mapped.CertificateChainPem); + Assert.Equal("KEY-PEM", mapped.PrivateKeyPem); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/PemCertificateBuilderTests.cs b/src/PSInfisicalAPI.Tests/PemCertificateBuilderTests.cs new file mode 100644 index 0000000..b147725 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/PemCertificateBuilderTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using PSInfisicalAPI.Pki; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class PemCertificateBuilderTests + { + private static (string CertPem, string KeyPem, string Thumbprint) CreateSelfSigned(string commonName) + { + using (RSA rsa = RSA.Create(2048)) + { + CertificateRequest request = new CertificateRequest( + "CN=" + commonName, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1); + using (X509Certificate2 cert = request.CreateSelfSigned(notBefore, notAfter)) + { + byte[] derBytes = cert.Export(X509ContentType.Cert); + string certPem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(derBytes, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + byte[] pkcs8 = rsa.ExportPkcs8PrivateKey(); + string keyPem = "-----BEGIN PRIVATE KEY-----\n" + + Convert.ToBase64String(pkcs8, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END PRIVATE KEY-----\n"; + + return (certPem, keyPem, cert.Thumbprint); + } + } + } + + [Fact] + public void Build_From_Cert_Only_Returns_X509Certificate2_Without_Key() + { + (string certPem, _, string thumbprint) = CreateSelfSigned("PemBuilderTest.NoKey"); + X509Certificate2 cert = PemCertificateBuilder.Build(certPem, null, null, X509KeyStorageFlags.DefaultKeySet); + try + { + Assert.NotNull(cert); + Assert.Equal(thumbprint, cert.Thumbprint); + Assert.False(cert.HasPrivateKey); + } + finally + { + cert.Dispose(); + } + } + + [Fact] + public void Build_With_Pkcs8_Key_Attaches_Private_Key() + { + (string certPem, string keyPem, string thumbprint) = CreateSelfSigned("PemBuilderTest.WithKey"); + X509Certificate2 cert = PemCertificateBuilder.Build(certPem, keyPem, null, X509KeyStorageFlags.Exportable); + try + { + Assert.NotNull(cert); + Assert.Equal(thumbprint, cert.Thumbprint); + Assert.True(cert.HasPrivateKey); + } + finally + { + cert.Dispose(); + } + } + + [Fact] + public void ReadCertificateChain_Returns_All_Certificates() + { + (string leafPem, _, _) = CreateSelfSigned("PemBuilderTest.Leaf"); + (string intermediatePem, _, _) = CreateSelfSigned("PemBuilderTest.Intermediate"); + string combined = leafPem + intermediatePem; + + System.Collections.Generic.List chain = PemCertificateBuilder.ReadCertificateChain(combined); + try + { + Assert.Equal(2, chain.Count); + } + finally + { + foreach (X509Certificate2 c in chain) { c.Dispose(); } + } + } + + [Fact] + public void Build_Empty_Certificate_Pem_Throws() + { + Assert.Throws(() => PemCertificateBuilder.Build(null, null, null, X509KeyStorageFlags.DefaultKeySet)); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/PkiEndpointRegistryTests.cs b/src/PSInfisicalAPI.Tests/PkiEndpointRegistryTests.cs new file mode 100644 index 0000000..70c3ca2 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/PkiEndpointRegistryTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class PkiEndpointRegistryTests + { + [Fact] + public void Get_ListInternalCertificateAuthorities_Returns_CertManager_Primary() + { + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListInternalCertificateAuthorities); + Assert.Equal("GET", definition.Method); + Assert.Equal("v1", definition.Version); + Assert.Equal("/api/v1/cert-manager/ca/internal", definition.Template); + Assert.True(definition.RequiresAuthorization); + } + + [Fact] + public void Get_RetrieveInternalCertificateAuthority_Has_CaId_Placeholder_Under_CertManager() + { + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveInternalCertificateAuthority); + Assert.Equal("GET", definition.Method); + Assert.Equal("/api/v1/cert-manager/ca/internal/{caId}", definition.Template); + } + + [Fact] + public void Get_SearchCertificates_Is_Post_With_ProjectId_Placeholder() + { + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.SearchCertificates); + Assert.Equal("POST", definition.Method); + Assert.Equal("/api/v1/projects/{projectId}/certificates/search", definition.Template); + Assert.True(definition.RequiresAuthorization); + } + + [Fact] + public void Get_GetCertificateBundle_Marks_Response_As_Secret_With_Pki_Primary() + { + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.GetCertificateBundle); + Assert.Equal("GET", definition.Method); + Assert.Equal("/api/v1/pki/certificates/{serialNumber}/bundle", definition.Template); + Assert.True(definition.ContainsSecretMaterialInResponse); + Assert.True(definition.RequiresAuthorization); + } + + [Fact] + public void Candidates_For_ListInternalCertificateAuthorities_Include_Pki_Legacy_Alias() + { + IReadOnlyList candidates = InfisicalEndpointRegistry.GetCandidates(InfisicalEndpointNames.ListInternalCertificateAuthorities); + Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/ca/internal"); + Assert.Contains(candidates, c => c.Template == "/api/v1/pki/ca/internal"); + } + + [Fact] + public void Candidates_For_RetrieveInternalCertificateAuthority_Include_Pki_Legacy_Alias() + { + IReadOnlyList candidates = InfisicalEndpointRegistry.GetCandidates(InfisicalEndpointNames.RetrieveInternalCertificateAuthority); + Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/ca/internal/{caId}"); + Assert.Contains(candidates, c => c.Template == "/api/v1/pki/ca/internal/{caId}"); + } + + [Fact] + public void Candidates_For_GetCertificateBundle_Include_CertManager_Alias() + { + IReadOnlyList candidates = InfisicalEndpointRegistry.GetCandidates(InfisicalEndpointNames.GetCertificateBundle); + Assert.Contains(candidates, c => c.Template == "/api/v1/pki/certificates/{serialNumber}/bundle"); + Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/certificates/{serialNumber}/bundle"); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs new file mode 100644 index 0000000..a7ee2e8 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs @@ -0,0 +1,84 @@ +using System; +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.ConvertTo, "InfisicalCertificate", DefaultParameterSetName = "FromPipeline")] + [OutputType(typeof(X509Certificate2))] + public sealed class ConvertToInfisicalCertificateCmdlet : InfisicalCmdletBase + { + [Parameter(ParameterSetName = "FromPipeline", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificate Certificate { get; set; } + + [Parameter(ParameterSetName = "FromBundle", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificateBundle Bundle { get; set; } + + [Parameter(ParameterSetName = "FromSerial", Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string SerialNumber { get; set; } + + [Parameter] public SwitchParameter NoPrivateKey { get; set; } + + [Parameter] public X509KeyStorageFlags KeyStorageFlags { get; set; } = X509KeyStorageFlags.DefaultKeySet; + + [Parameter] public SwitchParameter IncludeChain { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalCertificateBundle resolvedBundle = ResolveBundle(); + if (resolvedBundle == null) + { + return; + } + + string privateKeyPem = NoPrivateKey.IsPresent ? null : resolvedBundle.PrivateKeyPem; + X509Certificate2 cert = PemCertificateBuilder.Build(resolvedBundle.CertificatePem, privateKeyPem, resolvedBundle.CertificateChainPem, KeyStorageFlags); + WriteObject(cert); + + if (IncludeChain.IsPresent) + { + foreach (X509Certificate2 chainCert in PemCertificateBuilder.ReadCertificateChain(resolvedBundle.CertificateChainPem)) + { + WriteObject(chainCert); + } + } + } + catch (Exception exception) + { + ThrowTerminatingForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception); + } + } + + private InfisicalCertificateBundle ResolveBundle() + { + if (string.Equals(ParameterSetName, "FromBundle", StringComparison.Ordinal)) + { + return Bundle; + } + + string serial = null; + if (string.Equals(ParameterSetName, "FromSerial", StringComparison.Ordinal)) + { + serial = SerialNumber; + } + else if (string.Equals(ParameterSetName, "FromPipeline", StringComparison.Ordinal) && Certificate != null) + { + serial = Certificate.SerialNumber; + } + + if (string.IsNullOrEmpty(serial)) + { + return null; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + return client.GetCertificateBundle(connection, serial); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs new file mode 100644 index 0000000..5e1d40c --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Management.Automation; +using System.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Cmdlets +{ + public enum InfisicalCertificateExportFormat + { + Pem, + Pfx, + Cer + } + + [Cmdlet(VerbsData.Export, "InfisicalCertificate", SupportsShouldProcess = true, DefaultParameterSetName = "FromCertificate")] + [OutputType(typeof(FileInfo))] + public sealed class ExportInfisicalCertificateCmdlet : InfisicalCmdletBase + { + [Parameter(ParameterSetName = "FromCertificate", Mandatory = true, ValueFromPipeline = true)] + public X509Certificate2 Certificate { get; set; } + + [Parameter(ParameterSetName = "FromBundle", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificateBundle Bundle { get; set; } + + [Parameter(ParameterSetName = "FromInfisical", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificate InfisicalCertificate { get; set; } + + [Parameter(ParameterSetName = "FromSerial", Mandatory = true, Position = 1)] + public string SerialNumber { get; set; } + + [Parameter(Mandatory = true, Position = 0)] public string Path { get; set; } + [Parameter(Mandatory = true)] public InfisicalCertificateExportFormat Format { get; set; } + [Parameter] public SecureString Password { get; set; } + [Parameter] public SwitchParameter IncludeChain { get; set; } + [Parameter] public SwitchParameter NoPrivateKey { get; set; } + [Parameter] public SwitchParameter Force { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); + if (File.Exists(resolvedPath) && !Force.IsPresent) + { + throw new IOException(string.Concat("File '", resolvedPath, "' already exists. Pass -Force to overwrite.")); + } + + if (!ShouldProcess(resolvedPath, string.Concat("Export certificate as ", Format.ToString()))) + { + return; + } + + InfisicalCertificateBundle bundle = null; + X509Certificate2 cert = ResolveCertificate(out bundle); + if (cert == null && bundle == null) + { + return; + } + + switch (Format) + { + case InfisicalCertificateExportFormat.Pem: + WritePem(resolvedPath, cert, bundle); + break; + case InfisicalCertificateExportFormat.Pfx: + WritePfx(resolvedPath, cert); + break; + case InfisicalCertificateExportFormat.Cer: + WriteCer(resolvedPath, cert); + break; + } + + Logger.Information("ExportInfisicalCertificateCmdlet", string.Concat("Exported certificate to '", resolvedPath, "'.")); + if (PassThru.IsPresent) + { + WriteObject(new FileInfo(resolvedPath)); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception); + } + } + + private X509Certificate2 ResolveCertificate(out InfisicalCertificateBundle bundle) + { + bundle = null; + if (string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal)) + { + return Certificate; + } + + if (string.Equals(ParameterSetName, "FromBundle", StringComparison.Ordinal)) + { + bundle = Bundle; + } + else + { + string serial = string.Equals(ParameterSetName, "FromSerial", StringComparison.Ordinal) + ? SerialNumber + : (InfisicalCertificate != null ? InfisicalCertificate.SerialNumber : null); + if (string.IsNullOrEmpty(serial)) { return null; } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + bundle = client.GetCertificateBundle(connection, serial); + } + + if (bundle == null) { return null; } + string keyPem = NoPrivateKey.IsPresent ? null : bundle.PrivateKeyPem; + return PemCertificateBuilder.Build(bundle.CertificatePem, keyPem, bundle.CertificateChainPem, X509KeyStorageFlags.Exportable); + } + + private void WritePem(string path, X509Certificate2 cert, InfisicalCertificateBundle bundle) + { + StringBuilder sb = new StringBuilder(); + if (bundle != null && !string.IsNullOrEmpty(bundle.CertificatePem)) + { + sb.Append(bundle.CertificatePem.TrimEnd()).Append('\n'); + if (IncludeChain.IsPresent && !string.IsNullOrEmpty(bundle.CertificateChainPem)) + { + sb.Append(bundle.CertificateChainPem.TrimEnd()).Append('\n'); + } + + if (!NoPrivateKey.IsPresent && !string.IsNullOrEmpty(bundle.PrivateKeyPem)) + { + sb.Append(bundle.PrivateKeyPem.TrimEnd()).Append('\n'); + } + } + else + { + sb.Append("-----BEGIN CERTIFICATE-----\n"); + sb.Append(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)); + sb.Append("\n-----END CERTIFICATE-----\n"); + } + + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + } + + private void WritePfx(string path, X509Certificate2 cert) + { + byte[] bytes = Password != null + ? SecureStringUtility.UsePlainText(Password, plain => cert.Export(X509ContentType.Pfx, plain ?? string.Empty)) + : cert.Export(X509ContentType.Pfx); + + File.WriteAllBytes(path, bytes); + } + + private void WriteCer(string path, X509Certificate2 cert) + { + File.WriteAllBytes(path, cert.Export(X509ContentType.Cert)); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs new file mode 100644 index 0000000..d80992e --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs @@ -0,0 +1,50 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalCertificateAuthority", DefaultParameterSetName = "List")] + [OutputType(typeof(InfisicalCertificateAuthority))] + public sealed class GetInfisicalCertificateAuthorityCmdlet : InfisicalCmdletBase + { + [Parameter(ParameterSetName = "ById", Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string CaId { get; set; } + + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + 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); + if (ca != null) + { + WriteObject(ca); + } + + return; + } + + InfisicalCertificateAuthority[] all = client.ListInternalCertificateAuthorities(connection, resolvedProjectId); + foreach (InfisicalCertificateAuthority ca in all) + { + WriteObject(ca); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs new file mode 100644 index 0000000..af32a3e --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs @@ -0,0 +1,149 @@ +using System; +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsLifecycle.Install, "InfisicalCertificate", SupportsShouldProcess = true, DefaultParameterSetName = "FromCertificate")] + [OutputType(typeof(X509Certificate2))] + public sealed class InstallInfisicalCertificateCmdlet : InfisicalCmdletBase + { + [Parameter(ParameterSetName = "FromCertificate", Mandatory = true, ValueFromPipeline = true)] + public X509Certificate2 Certificate { get; set; } + + [Parameter(ParameterSetName = "FromInfisical", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificate InfisicalCertificate { get; set; } + + [Parameter(ParameterSetName = "FromSerial", Mandatory = true, Position = 0)] + public string SerialNumber { 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 IncludeChain { get; set; } + [Parameter] public SwitchParameter NoPrivateKey { get; set; } + [Parameter] public SwitchParameter Force { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + X509Certificate2 cert = ResolveCertificate(); + if (cert == null) + { + return; + } + + InstallCertificate(cert, StoreName, StoreLocation); + + if (IncludeChain.IsPresent && string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal) == false) + { + foreach (X509Certificate2 chainCert in ResolveChain()) + { + InstallCertificate(chainCert, StoreName.CertificateAuthority, StoreLocation); + } + } + + if (PassThru.IsPresent) + { + WriteObject(cert); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception); + } + } + + private void InstallCertificate(X509Certificate2 cert, StoreName storeName, StoreLocation storeLocation) + { + string target = string.Concat(storeLocation.ToString(), @"\", storeName.ToString(), " [", cert.Thumbprint, "]"); + X509Store store = new X509Store(storeName, storeLocation); + try + { + store.Open(OpenFlags.ReadWrite); + X509Certificate2Collection existing = store.Certificates.Find(X509FindType.FindByThumbprint, cert.Thumbprint, false); + if (existing.Count > 0) + { + if (!Force.IsPresent) + { + Logger.Information("InstallInfisicalCertificateCmdlet", string.Concat("Certificate already present in ", target, "; no action taken.")); + return; + } + + if (!ShouldProcess(target, "Replace existing certificate")) + { + return; + } + + store.RemoveRange(existing); + } + else if (!ShouldProcess(target, "Install certificate")) + { + return; + } + + store.Add(cert); + Logger.Information("InstallInfisicalCertificateCmdlet", string.Concat("Installed certificate to ", target, ".")); + } + finally + { + store.Close(); + } + } + + private X509Certificate2 ResolveCertificate() + { + if (string.Equals(ParameterSetName, "FromCertificate", StringComparison.Ordinal)) + { + return Certificate; + } + + string serial = null; + if (string.Equals(ParameterSetName, "FromSerial", StringComparison.Ordinal)) + { + serial = SerialNumber; + } + else if (InfisicalCertificate != null) + { + serial = InfisicalCertificate.SerialNumber; + } + + if (string.IsNullOrEmpty(serial)) + { + return null; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + InfisicalCertificateBundle bundle = client.GetCertificateBundle(connection, serial); + if (bundle == null) + { + return null; + } + + string keyPem = NoPrivateKey.IsPresent ? null : bundle.PrivateKeyPem; + return PemCertificateBuilder.Build(bundle.CertificatePem, keyPem, bundle.CertificateChainPem, KeyStorageFlags); + } + + private System.Collections.Generic.List ResolveChain() + { + string serial = string.Equals(ParameterSetName, "FromSerial", StringComparison.Ordinal) + ? SerialNumber + : (InfisicalCertificate != null ? InfisicalCertificate.SerialNumber : null); + if (string.IsNullOrEmpty(serial)) + { + return new System.Collections.Generic.List(); + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + InfisicalCertificateBundle bundle = client.GetCertificateBundle(connection, serial); + return bundle != null ? PemCertificateBuilder.ReadCertificateChain(bundle.CertificateChainPem) : new System.Collections.Generic.List(); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/SearchInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/SearchInfisicalCertificateCmdlet.cs new file mode 100644 index 0000000..4b3d3d7 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/SearchInfisicalCertificateCmdlet.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Search, "InfisicalCertificate")] + [OutputType(typeof(InfisicalCertificate))] + public sealed class SearchInfisicalCertificateCmdlet : InfisicalCmdletBase + { + [Parameter] public string ProjectId { get; set; } + [Parameter] public string FriendlyName { get; set; } + [Parameter] public string CommonName { get; set; } + [Parameter] public string Search { get; set; } + [Parameter] public string Status { get; set; } + [Parameter] public string[] CaId { get; set; } + [Parameter] public string[] ProfileId { get; set; } + [Parameter] public string[] ApplicationId { get; set; } + [Parameter] public string[] EnrollmentType { get; set; } + [Parameter] public string ExtendedKeyUsage { get; set; } + [Parameter] public string[] KeyAlgorithm { get; set; } + [Parameter] public string SignatureAlgorithm { get; set; } + [Parameter] public string[] Source { get; set; } + [Parameter] public DateTimeOffset? NotAfterFrom { get; set; } + [Parameter] public DateTimeOffset? NotAfterTo { get; set; } + [Parameter] public DateTimeOffset? NotBeforeFrom { get; set; } + [Parameter] public DateTimeOffset? NotBeforeTo { get; set; } + [Parameter] public string SortBy { get; set; } + [Parameter] [ValidateSet("asc", "desc")] public string SortOrder { 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(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + + InfisicalCertificateSearchQuery query = BuildQuery(resolvedProjectId); + int requestedLimit = query.Limit ?? 100; + query.Limit = requestedLimit; + query.Offset = query.Offset ?? 0; + + 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("SearchInfisicalCertificateCmdlet", "SearchCertificates", exception); + } + } + + private InfisicalCertificateSearchQuery BuildQuery(string projectId) + { + return new InfisicalCertificateSearchQuery + { + ProjectId = projectId, + FriendlyName = FriendlyName, + CommonName = CommonName, + Search = Search, + Status = Status, + CaIds = CaId, + ProfileIds = ProfileId, + ApplicationIds = ApplicationId, + EnrollmentTypes = EnrollmentType, + ExtendedKeyUsage = ExtendedKeyUsage, + KeyAlgorithm = KeyAlgorithm, + SignatureAlgorithm = SignatureAlgorithm, + Source = Source, + NotAfterFrom = NotAfterFrom, + NotAfterTo = NotAfterTo, + NotBeforeFrom = NotBeforeFrom, + NotBeforeTo = NotBeforeTo, + SortBy = SortBy, + SortOrder = SortOrder, + Limit = Limit, + Offset = Offset + }; + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs new file mode 100644 index 0000000..9ea61cb --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs @@ -0,0 +1,120 @@ +using System; +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsLifecycle.Uninstall, "InfisicalCertificate", SupportsShouldProcess = true, DefaultParameterSetName = "ByThumbprint")] + [OutputType(typeof(X509Certificate2))] + public sealed class UninstallInfisicalCertificateCmdlet : InfisicalCmdletBase + { + [Parameter(ParameterSetName = "ByThumbprint", Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string Thumbprint { get; set; } + + [Parameter(ParameterSetName = "ByCertificate", Mandatory = true, ValueFromPipeline = true)] + public X509Certificate2 Certificate { get; set; } + + [Parameter(ParameterSetName = "ByInfisical", Mandatory = true, ValueFromPipeline = true)] + public InfisicalCertificate InfisicalCertificate { get; set; } + + [Parameter(ParameterSetName = "BySubject", Mandatory = true)] + public string Subject { get; set; } + + [Parameter] public StoreName StoreName { get; set; } = StoreName.My; + [Parameter] public StoreLocation StoreLocation { get; set; } = StoreLocation.CurrentUser; + [Parameter] public SwitchParameter Force { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + X509Store store = new X509Store(StoreName, StoreLocation); + try + { + store.Open(OpenFlags.ReadWrite); + X509Certificate2Collection matches = FindMatches(store); + string target = string.Concat(StoreLocation.ToString(), @"\", StoreName.ToString()); + + if (matches == null || matches.Count == 0) + { + Logger.Information("UninstallInfisicalCertificateCmdlet", string.Concat("No matching certificates found in ", target, "; no action taken.")); + return; + } + + if (matches.Count > 1 && !Force.IsPresent) + { + throw new InvalidOperationException(string.Concat( + "Found ", matches.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + " matching certificates in ", target, ". Pass -Force to remove all of them.")); + } + + foreach (X509Certificate2 match in matches) + { + string description = string.Concat(target, " [", match.Thumbprint, "]"); + if (!ShouldProcess(description, "Remove certificate")) + { + continue; + } + + store.Remove(match); + Logger.Information("UninstallInfisicalCertificateCmdlet", string.Concat("Removed certificate from ", description, ".")); + + if (PassThru.IsPresent) + { + WriteObject(match); + } + } + } + finally + { + store.Close(); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception); + } + } + + private X509Certificate2Collection FindMatches(X509Store store) + { + string thumbprint = ResolveThumbprint(); + if (!string.IsNullOrEmpty(thumbprint)) + { + return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + } + + if (string.Equals(ParameterSetName, "BySubject", StringComparison.Ordinal)) + { + return store.Certificates.Find(X509FindType.FindBySubjectName, Subject, false); + } + + return null; + } + + private string ResolveThumbprint() + { + if (string.Equals(ParameterSetName, "ByThumbprint", StringComparison.Ordinal)) + { + return Thumbprint; + } + + if (string.Equals(ParameterSetName, "ByCertificate", StringComparison.Ordinal) && Certificate != null) + { + return Certificate.Thumbprint; + } + + if (string.Equals(ParameterSetName, "ByInfisical", StringComparison.Ordinal) && InfisicalCertificate != null) + { + if (!string.IsNullOrEmpty(InfisicalCertificate.FingerprintSha1)) + { + return InfisicalCertificate.FingerprintSha1.Replace(":", string.Empty).Replace(" ", string.Empty); + } + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs index 161c3a5..6d01d4a 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs @@ -43,5 +43,11 @@ namespace PSInfisicalAPI.Endpoints public const string CreateTag = "CreateTag"; public const string UpdateTag = "UpdateTag"; public const string DeleteTag = "DeleteTag"; + + public const string ListInternalCertificateAuthorities = "ListInternalCertificateAuthorities"; + public const string RetrieveInternalCertificateAuthority = "RetrieveInternalCertificateAuthority"; + public const string SearchCertificates = "SearchCertificates"; + public const string RetrieveCertificate = "RetrieveCertificate"; + public const string GetCertificateBundle = "GetCertificateBundle"; } } diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 4aceb25..9d1e305 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -16,6 +16,7 @@ namespace PSInfisicalAPI.Endpoints RegisterEnvironments(Candidates); RegisterFolders(Candidates); RegisterTags(Candidates); + RegisterPki(Candidates); } private static void Add(Dictionary> map, InfisicalEndpointDefinition definition) @@ -495,6 +496,101 @@ namespace PSInfisicalAPI.Endpoints }); } + private static void RegisterPki(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListInternalCertificateAuthorities, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/cert-manager/ca/internal", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListInternalCertificateAuthorities, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/pki/ca/internal", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveInternalCertificateAuthority, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/cert-manager/ca/internal/{caId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveInternalCertificateAuthority, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/pki/ca/internal/{caId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.SearchCertificates, + Resource = "Pki", + Version = "v1", + Method = "POST", + Template = "/api/v1/projects/{projectId}/certificates/search", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveCertificate, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/pki/certificates/{serialNumber}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveCertificate, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/cert-manager/certificates/{serialNumber}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.GetCertificateBundle, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/pki/certificates/{serialNumber}/bundle", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.GetCertificateBundle, + Resource = "Pki", + Version = "v1", + Method = "GET", + Template = "/api/v1/cert-manager/certificates/{serialNumber}/bundle", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + } + public static InfisicalEndpointDefinition Get(string name) { List list = GetCandidatesInternal(name); diff --git a/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs b/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs index d6e2872..ffd988e 100644 --- a/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs +++ b/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs @@ -43,6 +43,53 @@ namespace PSInfisicalAPI.Http throw exception; } + public InfisicalHttpResponse InvokeWithCandidateFallback( + InfisicalConnection connection, + string endpointName, + string operationName, + IDictionary pathParameters, + IEnumerable> queryParameters, + string body) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(endpointName)) { throw new ArgumentNullException(nameof(endpointName)); } + + IReadOnlyList candidates = InfisicalEndpointRegistry.GetCandidates(endpointName); + InfisicalApiException lastException = null; + + for (int index = 0; index < candidates.Count; index++) + { + InfisicalEndpointDefinition definition = candidates[index]; + Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); + InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, operationName, uri, body); + + if (response.StatusCode >= 200 && response.StatusCode < 300) + { + return response; + } + + InfisicalApiException exception = BuildApiException(response, definition); + response.Clear(); + + bool hasMoreCandidates = (index + 1) < candidates.Count; + if (hasMoreCandidates && IsRouteAliasMismatch(exception)) + { + lastException = exception; + continue; + } + + throw exception; + } + + throw lastException ?? new InfisicalApiException(string.Concat( + "All route candidates exhausted for '", endpointName, "'.")); + } + + private static bool IsRouteAliasMismatch(InfisicalApiException exception) + { + return exception.StatusCode == 404 || exception.StatusCode == 405; + } + private InfisicalHttpResponse ExecuteAuthorized( InfisicalConnection connection, InfisicalEndpointDefinition definition, diff --git a/src/PSInfisicalAPI/Models/InfisicalCertificate.cs b/src/PSInfisicalAPI/Models/InfisicalCertificate.cs new file mode 100644 index 0000000..fd6b936 --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalCertificate.cs @@ -0,0 +1,55 @@ +using System; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalCertificate + { + public string Id { get; set; } + public string ProjectId { get; set; } + public string CaId { get; set; } + public string CaName { get; set; } + public string CaCertId { get; set; } + public string CertificateTemplateId { get; set; } + public string ProfileId { get; set; } + public string ProfileName { get; set; } + public string ApplicationId { get; set; } + public string ApplicationName { get; set; } + public string PkiSubscriberId { get; set; } + public string Status { get; set; } + public string SerialNumber { get; set; } + public string FriendlyName { get; set; } + public string CommonName { get; set; } + public string AltNames { get; set; } + public string[] KeyUsages { get; set; } + public string[] ExtendedKeyUsages { get; set; } + public string KeyAlgorithm { get; set; } + public string SignatureAlgorithm { get; set; } + public string SubjectOrganization { get; set; } + public string SubjectOrganizationalUnit { get; set; } + public string SubjectCountry { get; set; } + public string SubjectState { get; set; } + public string SubjectLocality { get; set; } + public string FingerprintSha256 { get; set; } + public string FingerprintSha1 { get; set; } + public bool? IsCA { get; set; } + public int? PathLength { get; set; } + public string Source { get; set; } + public string EnrollmentType { get; set; } + public bool HasPrivateKey { get; set; } + public int? RevocationReason { get; set; } + public string RenewalError { get; set; } + public int? RenewBeforeDays { get; set; } + public string RenewedFromCertificateId { get; set; } + public string RenewedByCertificateId { get; set; } + public DateTimeOffset? NotBeforeUtc { get; set; } + public DateTimeOffset? NotAfterUtc { get; set; } + public DateTimeOffset? RevokedAtUtc { get; set; } + public DateTimeOffset? CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + + public override string ToString() + { + return FriendlyName ?? CommonName ?? SerialNumber ?? Id; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalCertificateAuthority.cs b/src/PSInfisicalAPI/Models/InfisicalCertificateAuthority.cs new file mode 100644 index 0000000..1737c6d --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalCertificateAuthority.cs @@ -0,0 +1,36 @@ +using System; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalCertificateAuthority + { + public string Id { get; set; } + public string ProjectId { get; set; } + public string Name { get; set; } + public string FriendlyName { get; set; } + public string Type { get; set; } + public string Status { get; set; } + public bool? EnableDirectIssuance { get; set; } + public string KeyAlgorithm { get; set; } + public string DistinguishedName { get; set; } + public string OrganizationName { get; set; } + public string OrganizationUnit { get; set; } + public string Country { get; set; } + public string State { get; set; } + public string Locality { get; set; } + public string CommonName { get; set; } + public int? MaxPathLength { get; set; } + public string NotBefore { get; set; } + public string NotAfter { get; set; } + public string SerialNumber { get; set; } + public string ParentCaId { get; set; } + public string ActiveCaCertId { get; set; } + public DateTimeOffset? CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + + public override string ToString() + { + return FriendlyName ?? Name ?? CommonName ?? Id; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalCertificateBundle.cs b/src/PSInfisicalAPI/Models/InfisicalCertificateBundle.cs new file mode 100644 index 0000000..b65f142 --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalCertificateBundle.cs @@ -0,0 +1,15 @@ +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalCertificateBundle + { + public string SerialNumber { get; set; } + public string CertificatePem { get; set; } + public string CertificateChainPem { get; set; } + public string PrivateKeyPem { get; set; } + + public override string ToString() + { + return SerialNumber; + } + } +} diff --git a/src/PSInfisicalAPI/PSInfisicalAPI.csproj b/src/PSInfisicalAPI/PSInfisicalAPI.csproj index 8c659ac..d219054 100644 --- a/src/PSInfisicalAPI/PSInfisicalAPI.csproj +++ b/src/PSInfisicalAPI/PSInfisicalAPI.csproj @@ -21,6 +21,7 @@ + diff --git a/src/PSInfisicalAPI/Pki/InfisicalCaDtos.cs b/src/PSInfisicalAPI/Pki/InfisicalCaDtos.cs new file mode 100644 index 0000000..01c45fc --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalCaDtos.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Pki +{ + internal sealed class InfisicalInternalCaResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("friendlyName")] public string FriendlyName { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("enableDirectIssuance")] public bool? EnableDirectIssuance { get; set; } + [JsonProperty("keyAlgorithm")] public string KeyAlgorithm { get; set; } + [JsonProperty("dn")] public string DistinguishedName { 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("commonName")] public string CommonName { get; set; } + [JsonProperty("maxPathLength")] public int? MaxPathLength { get; set; } + [JsonProperty("notBefore")] public string NotBefore { get; set; } + [JsonProperty("notAfter")] public string NotAfter { get; set; } + [JsonProperty("serialNumber")] public string SerialNumber { get; set; } + [JsonProperty("parentCaId")] public string ParentCaId { get; set; } + [JsonProperty("activeCaCertId")] public string ActiveCaCertId { get; set; } + [JsonProperty("createdAt")] public string CreatedAt { get; set; } + [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } + } + + internal sealed class InfisicalInternalCaListResponseDto + { + [JsonProperty("certificateAuthorities")] public List CertificateAuthorities { get; set; } + [JsonProperty("cas")] public List Cas { get; set; } + } + + internal sealed class InfisicalInternalCaSingleResponseDto + { + [JsonProperty("certificateAuthority")] public InfisicalInternalCaResponseDto CertificateAuthority { get; set; } + [JsonProperty("ca")] public InfisicalInternalCaResponseDto Ca { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalCaMapper.cs b/src/PSInfisicalAPI/Pki/InfisicalCaMapper.cs new file mode 100644 index 0000000..dc83822 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalCaMapper.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Pki +{ + internal static class InfisicalCaMapper + { + public static InfisicalCertificateAuthority Map(InfisicalInternalCaResponseDto dto, string fallbackProjectId) + { + if (dto == null) + { + return null; + } + + return new InfisicalCertificateAuthority + { + Id = dto.Id, + ProjectId = !string.IsNullOrEmpty(dto.ProjectId) ? dto.ProjectId : fallbackProjectId, + Name = dto.Name, + FriendlyName = dto.FriendlyName, + Type = dto.Type, + 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, + CreatedAtUtc = ParseTimestamp(dto.CreatedAt), + UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) + }; + } + + public static InfisicalCertificateAuthority[] MapMany(IEnumerable items, string fallbackProjectId) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalInternalCaResponseDto dto in items) + { + InfisicalCertificateAuthority mapped = Map(dto, fallbackProjectId); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + + private static DateTimeOffset? ParseTimestamp(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed)) + { + return parsed; + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs new file mode 100644 index 0000000..5996c44 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Pki +{ + internal sealed class InfisicalCertificateResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("caId")] public string CaId { get; set; } + [JsonProperty("caName")] public string CaName { get; set; } + [JsonProperty("caCertId")] public string CaCertId { get; set; } + [JsonProperty("certificateTemplateId")] public string CertificateTemplateId { get; set; } + [JsonProperty("profileId")] public string ProfileId { get; set; } + [JsonProperty("profileName")] public string ProfileName { get; set; } + [JsonProperty("applicationId")] public string ApplicationId { get; set; } + [JsonProperty("applicationName")] public string ApplicationName { get; set; } + [JsonProperty("pkiSubscriberId")] public string PkiSubscriberId { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("serialNumber")] public string SerialNumber { get; set; } + [JsonProperty("friendlyName")] public string FriendlyName { get; set; } + [JsonProperty("commonName")] public string CommonName { get; set; } + [JsonProperty("altNames")] public string AltNames { get; set; } + [JsonProperty("keyUsages")] public List KeyUsages { get; set; } + [JsonProperty("extendedKeyUsages")] public List ExtendedKeyUsages { get; set; } + [JsonProperty("keyAlgorithm")] public string KeyAlgorithm { get; set; } + [JsonProperty("signatureAlgorithm")] public string SignatureAlgorithm { get; set; } + [JsonProperty("subjectOrganization")] public string SubjectOrganization { get; set; } + [JsonProperty("subjectOrganizationalUnit")] public string SubjectOrganizationalUnit { get; set; } + [JsonProperty("subjectCountry")] public string SubjectCountry { get; set; } + [JsonProperty("subjectState")] public string SubjectState { get; set; } + [JsonProperty("subjectLocality")] public string SubjectLocality { get; set; } + [JsonProperty("fingerprintSha256")] public string FingerprintSha256 { get; set; } + [JsonProperty("fingerprintSha1")] public string FingerprintSha1 { get; set; } + [JsonProperty("isCA")] public bool? IsCA { get; set; } + [JsonProperty("pathLength")] public int? PathLength { get; set; } + [JsonProperty("source")] public string Source { get; set; } + [JsonProperty("enrollmentType")] public string EnrollmentType { get; set; } + [JsonProperty("hasPrivateKey")] public bool HasPrivateKey { get; set; } + [JsonProperty("revocationReason")] public int? RevocationReason { get; set; } + [JsonProperty("renewalError")] public string RenewalError { get; set; } + [JsonProperty("renewBeforeDays")] public int? RenewBeforeDays { get; set; } + [JsonProperty("renewedFromCertificateId")] public string RenewedFromCertificateId { get; set; } + [JsonProperty("renewedByCertificateId")] public string RenewedByCertificateId { get; set; } + [JsonProperty("notBefore")] public string NotBefore { get; set; } + [JsonProperty("notAfter")] public string NotAfter { get; set; } + [JsonProperty("revokedAt")] public string RevokedAt { get; set; } + [JsonProperty("createdAt")] public string CreatedAt { get; set; } + [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } + } + + internal sealed class InfisicalCertificateSearchResponseDto + { + [JsonProperty("certificates")] public List Certificates { get; set; } + [JsonProperty("totalCount")] public int TotalCount { get; set; } + } + + internal sealed class InfisicalCertificateSingleResponseDto + { + [JsonProperty("certificate")] public InfisicalCertificateResponseDto Certificate { get; set; } + } + + internal sealed class InfisicalCertificateSearchRequestDto + { + [JsonProperty("friendlyName", NullValueHandling = NullValueHandling.Ignore)] public string FriendlyName { get; set; } + [JsonProperty("commonName", NullValueHandling = NullValueHandling.Ignore)] public string CommonName { get; set; } + [JsonProperty("search", NullValueHandling = NullValueHandling.Ignore)] public string Search { get; set; } + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] public string Status { get; set; } + [JsonProperty("offset", NullValueHandling = NullValueHandling.Ignore)] public int? Offset { get; set; } + [JsonProperty("limit", NullValueHandling = NullValueHandling.Ignore)] public int? Limit { get; set; } + [JsonProperty("caIds", NullValueHandling = NullValueHandling.Ignore)] public string[] CaIds { get; set; } + [JsonProperty("profileIds", NullValueHandling = NullValueHandling.Ignore)] public string[] ProfileIds { get; set; } + [JsonProperty("applicationIds", NullValueHandling = NullValueHandling.Ignore)] public string[] ApplicationIds { get; set; } + [JsonProperty("enrollmentTypes", NullValueHandling = NullValueHandling.Ignore)] public string[] EnrollmentTypes { get; set; } + [JsonProperty("extendedKeyUsage", NullValueHandling = NullValueHandling.Ignore)] public string ExtendedKeyUsage { get; set; } + [JsonProperty("keyAlgorithm", NullValueHandling = NullValueHandling.Ignore)] public string[] KeyAlgorithm { get; set; } + [JsonProperty("signatureAlgorithm", NullValueHandling = NullValueHandling.Ignore)] public string SignatureAlgorithm { get; set; } + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] public string[] Source { get; set; } + [JsonProperty("fromDate", NullValueHandling = NullValueHandling.Ignore)] public string FromDate { get; set; } + [JsonProperty("toDate", NullValueHandling = NullValueHandling.Ignore)] public string ToDate { get; set; } + [JsonProperty("notAfterFrom", NullValueHandling = NullValueHandling.Ignore)] public string NotAfterFrom { get; set; } + [JsonProperty("notAfterTo", NullValueHandling = NullValueHandling.Ignore)] public string NotAfterTo { get; set; } + [JsonProperty("notBeforeFrom", NullValueHandling = NullValueHandling.Ignore)] public string NotBeforeFrom { get; set; } + [JsonProperty("notBeforeTo", NullValueHandling = NullValueHandling.Ignore)] public string NotBeforeTo { get; set; } + [JsonProperty("sortBy", NullValueHandling = NullValueHandling.Ignore)] public string SortBy { get; set; } + [JsonProperty("sortOrder", NullValueHandling = NullValueHandling.Ignore)] public string SortOrder { get; set; } + [JsonProperty("forPkiSync", NullValueHandling = NullValueHandling.Ignore)] public bool? ForPkiSync { get; set; } + } + + internal sealed class InfisicalCertificateBundleResponseDto + { + [JsonProperty("serialNumber")] public string SerialNumber { get; set; } + [JsonProperty("certificate")] public string Certificate { get; set; } + [JsonProperty("certificateChain")] public string CertificateChain { get; set; } + [JsonProperty("privateKey")] public string PrivateKey { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs new file mode 100644 index 0000000..064f284 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Pki +{ + internal static class InfisicalCertificateMapper + { + public static InfisicalCertificate Map(InfisicalCertificateResponseDto dto, string fallbackProjectId) + { + if (dto == null) + { + return null; + } + + return new InfisicalCertificate + { + Id = dto.Id, + ProjectId = !string.IsNullOrEmpty(dto.ProjectId) ? dto.ProjectId : fallbackProjectId, + CaId = dto.CaId, + CaName = dto.CaName, + CaCertId = dto.CaCertId, + CertificateTemplateId = dto.CertificateTemplateId, + ProfileId = dto.ProfileId, + ProfileName = dto.ProfileName, + ApplicationId = dto.ApplicationId, + ApplicationName = dto.ApplicationName, + PkiSubscriberId = dto.PkiSubscriberId, + Status = dto.Status, + SerialNumber = dto.SerialNumber, + FriendlyName = dto.FriendlyName, + CommonName = dto.CommonName, + AltNames = dto.AltNames, + KeyUsages = dto.KeyUsages != null ? dto.KeyUsages.ToArray() : null, + ExtendedKeyUsages = dto.ExtendedKeyUsages != null ? dto.ExtendedKeyUsages.ToArray() : null, + KeyAlgorithm = dto.KeyAlgorithm, + SignatureAlgorithm = dto.SignatureAlgorithm, + SubjectOrganization = dto.SubjectOrganization, + SubjectOrganizationalUnit = dto.SubjectOrganizationalUnit, + SubjectCountry = dto.SubjectCountry, + SubjectState = dto.SubjectState, + SubjectLocality = dto.SubjectLocality, + FingerprintSha256 = dto.FingerprintSha256, + FingerprintSha1 = dto.FingerprintSha1, + IsCA = dto.IsCA, + PathLength = dto.PathLength, + Source = dto.Source, + EnrollmentType = dto.EnrollmentType, + HasPrivateKey = dto.HasPrivateKey, + RevocationReason = dto.RevocationReason, + RenewalError = dto.RenewalError, + RenewBeforeDays = dto.RenewBeforeDays, + RenewedFromCertificateId = dto.RenewedFromCertificateId, + RenewedByCertificateId = dto.RenewedByCertificateId, + NotBeforeUtc = ParseTimestamp(dto.NotBefore), + NotAfterUtc = ParseTimestamp(dto.NotAfter), + RevokedAtUtc = ParseTimestamp(dto.RevokedAt), + CreatedAtUtc = ParseTimestamp(dto.CreatedAt), + UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) + }; + } + + public static InfisicalCertificate[] MapMany(IEnumerable items, string fallbackProjectId) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalCertificateResponseDto dto in items) + { + InfisicalCertificate mapped = Map(dto, fallbackProjectId); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + + public static InfisicalCertificateBundle MapBundle(InfisicalCertificateBundleResponseDto dto) + { + if (dto == null) + { + return null; + } + + return new InfisicalCertificateBundle + { + SerialNumber = dto.SerialNumber, + CertificatePem = dto.Certificate, + CertificateChainPem = dto.CertificateChain, + PrivateKeyPem = dto.PrivateKey + }; + } + + private static DateTimeOffset? ParseTimestamp(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed)) + { + return parsed; + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateSearchQuery.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateSearchQuery.cs new file mode 100644 index 0000000..f655a51 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateSearchQuery.cs @@ -0,0 +1,32 @@ +using System; + +namespace PSInfisicalAPI.Pki +{ + public sealed class InfisicalCertificateSearchQuery + { + public string ProjectId { get; set; } + public string FriendlyName { get; set; } + public string CommonName { get; set; } + public string Search { get; set; } + public string Status { get; set; } + public int? Offset { get; set; } + public int? Limit { get; set; } + public string[] CaIds { get; set; } + public string[] ProfileIds { get; set; } + public string[] ApplicationIds { get; set; } + public string[] EnrollmentTypes { get; set; } + public string ExtendedKeyUsage { get; set; } + public string[] KeyAlgorithm { get; set; } + public string SignatureAlgorithm { get; set; } + public string[] Source { get; set; } + public DateTimeOffset? FromDate { get; set; } + public DateTimeOffset? ToDate { get; set; } + public DateTimeOffset? NotAfterFrom { get; set; } + public DateTimeOffset? NotAfterTo { get; set; } + public DateTimeOffset? NotBeforeFrom { get; set; } + public DateTimeOffset? NotBeforeTo { get; set; } + public string SortBy { get; set; } + public string SortOrder { get; set; } + public bool? ForPkiSync { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs new file mode 100644 index 0000000..06b38a3 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Pki +{ + public sealed class InfisicalPkiClient + { + private const string Component = "PkiClient"; + + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + private readonly InfisicalApiInvoker _invoker; + + public InfisicalPkiClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _serializer = new JsonInfisicalSerializer(); + _invoker = new InfisicalApiInvoker(httpClient); + } + + public InfisicalCertificateAuthority[] ListInternalCertificateAuthorities(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + + List> query = null; + if (!string.IsNullOrEmpty(resolvedProjectId)) + { + query = new List> { new KeyValuePair("projectId", resolvedProjectId) }; + } + + 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(response.Body); + response.Clear(); + + List source = dto != null ? (dto.CertificateAuthorities ?? dto.Cas) : null; + InfisicalCertificateAuthority[] mapped = InfisicalCaMapper.MapMany(source, resolvedProjectId); + _logger.Information(Component, "Infisical internal certificate authority list retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical internal certificate authority list retrieval failed."); + throw; + } + } + + public InfisicalCertificateAuthority GetInternalCertificateAuthority(InfisicalConnection connection, string caId, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(caId)) { throw new InfisicalConfigurationException("CaId is required."); } + + Dictionary pathParameters = new Dictionary { { "caId", caId } }; + + 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(response.Body); + response.Clear(); + + InfisicalInternalCaResponseDto inner = dto != null ? (dto.CertificateAuthority ?? dto.Ca) : null; + if (inner == null) + { + inner = _serializer.Deserialize(response.Body); + } + + InfisicalCertificateAuthority mapped = InfisicalCaMapper.Map(inner, FirstNonEmpty(projectId, connection.ProjectId)); + _logger.Information(Component, "Infisical internal certificate authority retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical internal certificate authority retrieval failed."); + throw; + } + } + + public InfisicalCertificateSearchResult SearchCertificates(InfisicalConnection connection, InfisicalCertificateSearchQuery query) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (query == null) { throw new ArgumentNullException(nameof(query)); } + string resolvedProjectId = FirstNonEmpty(query.ProjectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId } }; + InfisicalCertificateSearchRequestDto request = BuildSearchRequest(query); + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, "Attempting to search Infisical certificates. Please Wait..."); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.SearchCertificates, "SearchCertificates", pathParameters, null, body); + InfisicalCertificateSearchResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalCertificate[] mapped = InfisicalCertificateMapper.MapMany(dto != null ? dto.Certificates : null, resolvedProjectId); + int total = dto != null ? dto.TotalCount : mapped.Length; + _logger.Information(Component, "Infisical certificate search was successful."); + return new InfisicalCertificateSearchResult { Certificates = mapped, TotalCount = total }; + } + catch (Exception) + { + _logger.Error(Component, "Infisical certificate search failed."); + throw; + } + } + + public InfisicalCertificateBundle GetCertificateBundle(InfisicalConnection connection, string serialNumber) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(serialNumber)) { throw new InfisicalConfigurationException("SerialNumber is required."); } + + Dictionary pathParameters = new Dictionary { { "serialNumber", serialNumber } }; + + try + { + _logger.Information(Component, string.Concat("Attempting to retrieve Infisical certificate bundle for '", serialNumber, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.GetCertificateBundle, "GetCertificateBundle", pathParameters, null, null); + InfisicalCertificateBundleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalCertificateBundle mapped = InfisicalCertificateMapper.MapBundle(dto); + _logger.Information(Component, "Infisical certificate bundle retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical certificate bundle retrieval failed."); + throw; + } + } + + internal static InfisicalCertificateSearchRequestDto BuildSearchRequest(InfisicalCertificateSearchQuery query) + { + return new InfisicalCertificateSearchRequestDto + { + FriendlyName = query.FriendlyName, + CommonName = query.CommonName, + Search = query.Search, + Status = query.Status, + Offset = query.Offset, + Limit = query.Limit, + CaIds = query.CaIds, + ProfileIds = query.ProfileIds, + ApplicationIds = query.ApplicationIds, + EnrollmentTypes = query.EnrollmentTypes, + ExtendedKeyUsage = query.ExtendedKeyUsage, + KeyAlgorithm = query.KeyAlgorithm, + SignatureAlgorithm = query.SignatureAlgorithm, + Source = query.Source, + FromDate = FormatTimestamp(query.FromDate), + ToDate = FormatTimestamp(query.ToDate), + NotAfterFrom = FormatTimestamp(query.NotAfterFrom), + NotAfterTo = FormatTimestamp(query.NotAfterTo), + NotBeforeFrom = FormatTimestamp(query.NotBeforeFrom), + NotBeforeTo = FormatTimestamp(query.NotBeforeTo), + SortBy = query.SortBy, + SortOrder = query.SortOrder, + ForPkiSync = query.ForPkiSync + }; + } + + private static string FormatTimestamp(DateTimeOffset? value) + { + if (!value.HasValue) { return null; } + return value.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) { return null; } + foreach (string value in values) { if (!string.IsNullOrEmpty(value)) { return value; } } + return null; + } + } + + public sealed class InfisicalCertificateSearchResult + { + public InfisicalCertificate[] Certificates { get; set; } + public int TotalCount { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Pki/PemCertificateBuilder.cs b/src/PSInfisicalAPI/Pki/PemCertificateBuilder.cs new file mode 100644 index 0000000..e4e26ac --- /dev/null +++ b/src/PSInfisicalAPI/Pki/PemCertificateBuilder.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using BcX509Certificate = Org.BouncyCastle.X509.X509Certificate; + +namespace PSInfisicalAPI.Pki +{ + public static class PemCertificateBuilder + { + public static X509Certificate2 Build(string certificatePem, string privateKeyPem, string chainPem, X509KeyStorageFlags storageFlags) + { + if (string.IsNullOrEmpty(certificatePem)) + { + throw new ArgumentException("Certificate PEM is required.", nameof(certificatePem)); + } + + BcX509Certificate leaf = ReadFirstCertificate(certificatePem); + List chain = ReadAllCertificates(chainPem); + + if (string.IsNullOrEmpty(privateKeyPem)) + { + return new X509Certificate2(leaf.GetEncoded()); + } + + AsymmetricKeyParameter privateKey = ReadPrivateKey(privateKeyPem); + + Pkcs12StoreBuilder builder = new Pkcs12StoreBuilder(); + Pkcs12Store store = builder.Build(); + + const string alias = "infisical-cert"; + List entries = new List(); + entries.Add(new X509CertificateEntry(leaf)); + foreach (BcX509Certificate intermediate in chain) + { + entries.Add(new X509CertificateEntry(intermediate)); + } + + store.SetKeyEntry(alias, new AsymmetricKeyEntry(privateKey), entries.ToArray()); + + char[] password = GenerateRandomPassword(); + using (MemoryStream ms = new MemoryStream()) + { + store.Save(ms, password, new SecureRandom()); + byte[] pfxBytes = ms.ToArray(); + return new X509Certificate2(pfxBytes, new string(password), storageFlags); + } + } + + public static List ReadCertificateChain(string chainPem) + { + List results = new List(); + if (string.IsNullOrEmpty(chainPem)) + { + return results; + } + + foreach (BcX509Certificate cert in ReadAllCertificates(chainPem)) + { + results.Add(new X509Certificate2(cert.GetEncoded())); + } + + return results; + } + + private static BcX509Certificate ReadFirstCertificate(string pem) + { + using (StringReader reader = new StringReader(pem)) + { + PemReader pemReader = new PemReader(reader); + object obj = pemReader.ReadObject(); + while (obj != null) + { + BcX509Certificate cert = obj as BcX509Certificate; + if (cert != null) + { + return cert; + } + + obj = pemReader.ReadObject(); + } + } + + throw new InvalidOperationException("No certificate found in PEM input."); + } + + private static List ReadAllCertificates(string pem) + { + List results = new List(); + if (string.IsNullOrEmpty(pem)) + { + return results; + } + + using (StringReader reader = new StringReader(pem)) + { + PemReader pemReader = new PemReader(reader); + object obj = pemReader.ReadObject(); + while (obj != null) + { + BcX509Certificate cert = obj as BcX509Certificate; + if (cert != null) + { + results.Add(cert); + } + + obj = pemReader.ReadObject(); + } + } + + return results; + } + + private static AsymmetricKeyParameter ReadPrivateKey(string pem) + { + using (StringReader reader = new StringReader(pem)) + { + PemReader pemReader = new PemReader(reader); + object obj = pemReader.ReadObject(); + while (obj != null) + { + AsymmetricKeyParameter key = obj as AsymmetricKeyParameter; + if (key != null && key.IsPrivate) + { + return key; + } + + AsymmetricCipherKeyPair pair = obj as AsymmetricCipherKeyPair; + if (pair != null) + { + return pair.Private; + } + + obj = pemReader.ReadObject(); + } + } + + throw new InvalidOperationException("No private key found in PEM input."); + } + + private static char[] GenerateRandomPassword() + { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[24]; + random.NextBytes(bytes); + return Convert.ToBase64String(bytes).ToCharArray(); + } + } +}