mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4846 from Infisical/PKI-9
PKI: add support to export certs in PKCS12 format
This commit is contained in:
32
backend/package-lock.json
generated
32
backend/package-lock.json
generated
@@ -58,6 +58,7 @@
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.2",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"acme-client": "^5.4.0",
|
||||
"ajv": "^8.12.0",
|
||||
@@ -97,6 +98,7 @@
|
||||
"ms": "^2.1.3",
|
||||
"mysql2": "^3.9.8",
|
||||
"nanoid": "^3.3.8",
|
||||
"node-forge": "^1.3.1",
|
||||
"nodemailer": "^6.9.9",
|
||||
"oci-sdk": "^2.108.0",
|
||||
"odbc": "^2.4.9",
|
||||
@@ -15272,6 +15274,15 @@
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -33526,18 +33537,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/@types/node": {
|
||||
"version": "24.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -33569,15 +33568,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vite-node/node_modules/vite": {
|
||||
"version": "7.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.2",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"acme-client": "^5.4.0",
|
||||
"ajv": "^8.12.0",
|
||||
@@ -224,6 +225,7 @@
|
||||
"ms": "^2.1.3",
|
||||
"mysql2": "^3.9.8",
|
||||
"nanoid": "^3.3.8",
|
||||
"node-forge": "^1.3.1",
|
||||
"nodemailer": "^6.9.9",
|
||||
"oci-sdk": "^2.108.0",
|
||||
"odbc": "^2.4.9",
|
||||
|
||||
@@ -323,6 +323,7 @@ export enum EventType {
|
||||
GET_CERT_BODY = "get-cert-body",
|
||||
GET_CERT_PRIVATE_KEY = "get-cert-private-key",
|
||||
GET_CERT_BUNDLE = "get-cert-bundle",
|
||||
EXPORT_CERT_PKCS12 = "export-cert-pkcs12",
|
||||
CREATE_PKI_ALERT = "create-pki-alert",
|
||||
GET_PKI_ALERT = "get-pki-alert",
|
||||
UPDATE_PKI_ALERT = "update-pki-alert",
|
||||
@@ -2315,6 +2316,14 @@ interface GetCertBundle {
|
||||
serialNumber: string;
|
||||
};
|
||||
}
|
||||
interface GetCertPkcs12 {
|
||||
type: EventType.EXPORT_CERT_PKCS12;
|
||||
metadata: {
|
||||
certId: string;
|
||||
cn: string;
|
||||
serialNumber: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreatePkiAlert {
|
||||
type: EventType.CREATE_PKI_ALERT;
|
||||
@@ -4252,6 +4261,7 @@ export type Event =
|
||||
| GetCertBody
|
||||
| GetCertPrivateKey
|
||||
| GetCertBundle
|
||||
| GetCertPkcs12
|
||||
| CreatePkiAlert
|
||||
| GetPkiAlert
|
||||
| UpdatePkiAlert
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CertificatesSchema } from "@app/db/schemas";
|
||||
@@ -616,4 +617,64 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:serialNumber/pkcs12",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
schema: {
|
||||
hide: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "Download certificate in PKCS12 format",
|
||||
params: z.object({
|
||||
serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber)
|
||||
}),
|
||||
body: z.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(6, "Password must be at least 6 characters long")
|
||||
.describe("Password for the keystore (minimum 6 characters)"),
|
||||
alias: z.string().min(1, "Alias is required").describe("Alias for the certificate in the keystore")
|
||||
}),
|
||||
response: {
|
||||
200: z.any().describe("PKCS12 keystore as binary data")
|
||||
}
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({
|
||||
serialNumber: req.params.serialNumber,
|
||||
password: req.body.password,
|
||||
alias: req.body.alias,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: cert.projectId,
|
||||
event: {
|
||||
type: EventType.EXPORT_CERT_PKCS12,
|
||||
metadata: {
|
||||
certId: cert.id,
|
||||
cn: cert.commonName,
|
||||
serialNumber: cert.serialNumber
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addNoCacheHeaders(reply);
|
||||
reply.header("Content-Type", "application/octet-stream");
|
||||
reply.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"`
|
||||
);
|
||||
|
||||
return pkcs12Data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import forge from "node-forge";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
@@ -104,3 +105,53 @@ export const getCertificateCredentials = async ({
|
||||
throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` });
|
||||
}
|
||||
};
|
||||
|
||||
export const generatePkcs12FromCertificate = async ({
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey,
|
||||
password,
|
||||
alias
|
||||
}: {
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
privateKey: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
}): Promise<Buffer> => {
|
||||
try {
|
||||
if (!password || password.trim() === "") {
|
||||
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
|
||||
}
|
||||
|
||||
const cert = forge.pki.certificateFromPem(certificate);
|
||||
const key = forge.pki.privateKeyFromPem(privateKey);
|
||||
|
||||
const chainCerts = [];
|
||||
if (certificateChain) {
|
||||
const chainPems = splitPemChain(certificateChain);
|
||||
for (const chainPem of chainPems) {
|
||||
try {
|
||||
const chainCert = forge.pki.certificateFromPem(chainPem);
|
||||
chainCerts.push(chainCert);
|
||||
} catch (error) {
|
||||
// Skip invalid certificates in chain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PKCS12 file
|
||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chainCerts], password, {
|
||||
algorithm: "aes256", // Modern AES-256 encryption
|
||||
friendlyName: alias
|
||||
});
|
||||
|
||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||
|
||||
return Buffer.from(p12Der, "binary");
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to generate PKCS12 keystore: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,12 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
|
||||
import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns";
|
||||
import {
|
||||
generatePkcs12FromCertificate,
|
||||
getCertificateCredentials,
|
||||
revocationReasonToCrlCode,
|
||||
splitPemChain
|
||||
} from "./certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
TGetCertBodyDTO,
|
||||
TGetCertBundleDTO,
|
||||
TGetCertDTO,
|
||||
TGetCertPkcs12DTO,
|
||||
TGetCertPrivateKeyDTO,
|
||||
TImportCertDTO,
|
||||
TRevokeCertDTO
|
||||
@@ -656,6 +662,71 @@ export const certificateServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getCertPkcs12 = async ({
|
||||
serialNumber,
|
||||
password,
|
||||
alias,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TGetCertPkcs12DTO) => {
|
||||
if (!password || password.trim() === "") {
|
||||
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestError({
|
||||
message: "Password must be at least 6 characters long for PKCS12 keystore security"
|
||||
});
|
||||
}
|
||||
|
||||
if (!alias || alias.trim() === "") {
|
||||
throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" });
|
||||
}
|
||||
const cert = await certificateDAL.findOne({ serialNumber });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: cert.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
// Get certificate bundle (certificate, chain, private key)
|
||||
const { certificate, certificateChain, privateKey } = await getCertBundle({
|
||||
serialNumber,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
if (!privateKey) {
|
||||
throw new BadRequestError({ message: "Certificate private key is required for PKCS12 export" });
|
||||
}
|
||||
|
||||
const pkcs12Data = await generatePkcs12FromCertificate({
|
||||
certificate,
|
||||
certificateChain: certificateChain || "",
|
||||
privateKey,
|
||||
password,
|
||||
alias
|
||||
});
|
||||
|
||||
return {
|
||||
pkcs12Data,
|
||||
cert
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getCert,
|
||||
getCertPrivateKey,
|
||||
@@ -663,6 +734,7 @@ export const certificateServiceFactory = ({
|
||||
revokeCert,
|
||||
getCertBody,
|
||||
importCert,
|
||||
getCertBundle
|
||||
getCertBundle,
|
||||
getCertPkcs12
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,6 +119,12 @@ export type TGetCertBundleDTO = {
|
||||
serialNumber: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetCertPkcs12DTO = {
|
||||
serialNumber: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetCertificateCredentialsDTO = {
|
||||
certId: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -60,6 +60,107 @@ The following examples demonstrate different approaches to certificate renewal:
|
||||
- Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets.
|
||||
- Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination.
|
||||
|
||||
## Guide to Exporting Certificates
|
||||
|
||||
In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure.
|
||||
|
||||
### Accessing the Export Certificate Modal
|
||||
|
||||
To export any certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu.
|
||||
|
||||

|
||||
|
||||
<Tabs>
|
||||
<Tab title="PEM Format">
|
||||
<Steps>
|
||||
<Step title="Exporting in PEM Format">
|
||||
In the export modal, choose **PEM** as the format and click **Export**.
|
||||
|
||||

|
||||
|
||||
The PEM export modal will display the certificate details including:
|
||||
- **Serial Number**: The unique identifier for the certificate
|
||||
- **Certificate Body**: The X.509 certificate in PEM format
|
||||
- **Certificate Chain**: The intermediate and root CA certificates
|
||||
- **Private Key**: The private key associated with the certificate (if available)
|
||||
|
||||

|
||||
|
||||
You can copy each component individually or use the **Copy All** button to copy the complete certificate bundle.
|
||||
</Step>
|
||||
<Step title="Using PEM Certificates">
|
||||
PEM format certificates can be used directly with most web servers and applications:
|
||||
|
||||
- **Apache HTTP Server**: Configure SSL certificates in your virtual host
|
||||
- **Nginx**: Use the certificate and private key files in your server configuration
|
||||
- **Docker containers**: Mount certificate files for TLS-enabled applications
|
||||
- **Load balancers**: Upload PEM certificates to AWS ALB, Azure Application Gateway, etc.
|
||||
|
||||
Example Nginx configuration:
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name example.com;
|
||||
|
||||
ssl_certificate /path/to/certificate.pem;
|
||||
ssl_certificate_key /path/to/private-key.pem;
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
</Tab>
|
||||
<Tab title="PKCS12 Format">
|
||||
<Steps>
|
||||
<Step title="Exporting in PKCS12 Format">
|
||||
In the export modal, choose **PKCS12** as the format and provide the required configuration:
|
||||
|
||||

|
||||
|
||||
- **Password**: A secure password to protect the PKCS12 keystore
|
||||
- **Alias**: A friendly name for the certificate within the keystore
|
||||
|
||||
Click **Export** to generate and download the `.p12` file containing the certificate, certificate chain, and private key.
|
||||
</Step>
|
||||
<Step title="Using PKCS12 Certificates">
|
||||
PKCS12 files (`.p12` extension) are binary keystore files that contain the certificate, certificate chain, and private key in a single encrypted file:
|
||||
|
||||
- **Java applications**: Import directly into Java KeyStore (JKS) or use with SSL/TLS
|
||||
- **Windows IIS**: Import the PKCS12 file for web server SSL configuration
|
||||
- **Browser certificates**: Install client certificates for authentication
|
||||
- **Mobile applications**: Deploy certificates to iOS and Android applications
|
||||
|
||||
To verify the contents of a PKCS12 file:
|
||||
```bash
|
||||
openssl pkcs12 -in certificate.p12 -nokeys -clcerts
|
||||
```
|
||||
|
||||
To extract the private key:
|
||||
```bash
|
||||
openssl pkcs12 -in certificate.p12 -nocerts -out private-key.pem
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you need to convert the PKCS12 file to Java KeyStore (JKS) format for applications running on Java 8 or earlier, use the following keytool command:
|
||||
|
||||
```bash
|
||||
keytool -importkeystore \
|
||||
-srckeystore certificate.p12 \
|
||||
-srcstoretype PKCS12 \
|
||||
-srcstorepass <p12-password> \
|
||||
-destkeystore certificate.jks \
|
||||
-deststoretype JKS \
|
||||
-deststorepass <jks-password>
|
||||
```
|
||||
|
||||
Replace `<p12-password>` with the password you used when exporting the PKCS12 file, and `<jks-password>` with your desired JKS keystore password.
|
||||
|
||||
The resulting `.jks` file can then be used with Java applications that require JKS format keystores.
|
||||
</Info>
|
||||
</Step>
|
||||
</Steps>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Guide to Revoking Certificates
|
||||
|
||||
In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA.
|
||||
|
||||
BIN
docs/images/platform/pki/certificate/cert-export-option.png
Normal file
BIN
docs/images/platform/pki/certificate/cert-export-option.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
BIN
docs/images/platform/pki/certificate/cert-export-pem-modal.png
Normal file
BIN
docs/images/platform/pki/certificate/cert-export-pem-modal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 KiB |
BIN
docs/images/platform/pki/certificate/cert-export-pem.png
Normal file
BIN
docs/images/platform/pki/certificate/cert-export-pem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
docs/images/platform/pki/certificate/cert-export-pkcs12.png
Normal file
BIN
docs/images/platform/pki/certificate/cert-export-pkcs12.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -1,6 +1,7 @@
|
||||
export { CertStatus } from "./enums";
|
||||
export {
|
||||
useDeleteCert,
|
||||
useDownloadCertPkcs12,
|
||||
useImportCertificate,
|
||||
useRenewCertificate,
|
||||
useRevokeCert,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { projectKeys } from "../projects";
|
||||
import {
|
||||
TCertificate,
|
||||
TDeleteCertDTO,
|
||||
TDownloadPkcs12DTO,
|
||||
TImportCertificateDTO,
|
||||
TImportCertificateResponse,
|
||||
TRenewCertificateDTO,
|
||||
@@ -134,3 +135,42 @@ export const useUpdateRenewalConfig = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadCertPkcs12 = () => {
|
||||
return useMutation<void, object, TDownloadPkcs12DTO>({
|
||||
mutationFn: async ({ serialNumber, projectSlug, password, alias }) => {
|
||||
try {
|
||||
const response = await apiRequest.post(
|
||||
`/api/v1/pki/certificates/${serialNumber}/pkcs12`,
|
||||
{
|
||||
password,
|
||||
alias
|
||||
},
|
||||
{
|
||||
params: { projectSlug },
|
||||
responseType: "arraybuffer"
|
||||
}
|
||||
);
|
||||
|
||||
// Create blob and trigger download
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `certificate-${serialNumber}.p12`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
if (error.response?.data instanceof ArrayBuffer) {
|
||||
const decoder = new TextDecoder();
|
||||
const errorText = decoder.decode(error.response.data);
|
||||
const errorData = JSON.parse(errorText);
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,3 +72,10 @@ export type TUpdateRenewalConfigDTO = {
|
||||
enableAutoRenewal?: boolean;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TDownloadPkcs12DTO = {
|
||||
serialNumber: string;
|
||||
projectSlug: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faDownload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificateExport"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["certificateExport"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
onFormatSelected: (
|
||||
format: "pem" | "pkcs12",
|
||||
serialNumber: string,
|
||||
options?: ExportOptions
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type CertificateExportFormat = "pem" | "pkcs12";
|
||||
|
||||
export type ExportOptions = {
|
||||
pkcs12?: {
|
||||
password: string;
|
||||
alias: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<CertificateExportFormat>("pem");
|
||||
const [pkcs12Options, setPkcs12Options] = useState({
|
||||
password: "",
|
||||
alias: ""
|
||||
});
|
||||
|
||||
const serialNumber =
|
||||
(popUp?.certificateExport?.data as { serialNumber: string })?.serialNumber || "";
|
||||
|
||||
// Reset form whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (popUp?.certificateExport?.isOpen) {
|
||||
setSelectedFormat("pem");
|
||||
setPkcs12Options({
|
||||
password: "",
|
||||
alias: ""
|
||||
});
|
||||
}
|
||||
}, [popUp?.certificateExport?.isOpen]);
|
||||
|
||||
const isFormValid = () => {
|
||||
if (selectedFormat === "pkcs12") {
|
||||
return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (serialNumber && isFormValid()) {
|
||||
const options: ExportOptions = {};
|
||||
|
||||
if (selectedFormat === "pkcs12") {
|
||||
options.pkcs12 = pkcs12Options;
|
||||
}
|
||||
|
||||
onFormatSelected(selectedFormat, serialNumber, options);
|
||||
handlePopUpToggle("certificateExport", false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.certificateExport?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("certificateExport", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Export Certificate">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Choose the format for exporting your certificate</p>
|
||||
|
||||
<FormControl
|
||||
label="Export Format"
|
||||
helperText={
|
||||
selectedFormat === "pem"
|
||||
? "Privacy Enhanced Mail - Text-based certificate format"
|
||||
: "PKCS12 format - Binary keystore format compatible with Java applications"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedFormat}
|
||||
onValueChange={(value) => setSelectedFormat(value as CertificateExportFormat)}
|
||||
>
|
||||
<SelectItem value="pem">PEM Format</SelectItem>
|
||||
<SelectItem value="pkcs12">PKCS12 Format</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedFormat === "pkcs12" && (
|
||||
<>
|
||||
<FormControl
|
||||
label="Keystore Password"
|
||||
helperText={
|
||||
pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6
|
||||
? undefined
|
||||
: "Password to protect the PKCS12 keystore (minimum 6 characters)"
|
||||
}
|
||||
isError={pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6}
|
||||
errorText="Password must be at least 6 characters long"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter keystore password"
|
||||
value={pkcs12Options.password}
|
||||
onChange={(e) =>
|
||||
setPkcs12Options((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Certificate Alias"
|
||||
helperText="Friendly name for the certificate in the keystore"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter certificate alias"
|
||||
value={pkcs12Options.alias}
|
||||
onChange={(e) => setPkcs12Options((prev) => ({ ...prev, alias: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpToggle("certificateExport", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faDownload} />}
|
||||
onClick={handleExport}
|
||||
disabled={!serialNumber || !isFormValid()}
|
||||
>
|
||||
Export {selectedFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
ProjectPermissionSub,
|
||||
useProject
|
||||
} from "@app/context";
|
||||
import { useDeleteCert } from "@app/hooks/api";
|
||||
import { useDeleteCert, useDownloadCertPkcs12 } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { CertificateCertModal } from "./CertificateCertModal";
|
||||
import { CertificateExportModal, ExportOptions } from "./CertificateExportModal";
|
||||
import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
|
||||
@@ -24,11 +25,13 @@ import { CertificatesTable } from "./CertificatesTable";
|
||||
export const CertificatesSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: deleteCert } = useDeleteCert();
|
||||
const { mutateAsync: downloadCertPkcs12 } = useDownloadCertPkcs12();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"issueCertificate",
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
"certificateExport",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"manageRenewal",
|
||||
@@ -49,6 +52,45 @@ export const CertificatesSection = () => {
|
||||
handlePopUpClose("deleteCertificate");
|
||||
};
|
||||
|
||||
const handleCertificateExport = async (
|
||||
format: "pem" | "pkcs12",
|
||||
serialNumber: string,
|
||||
options?: ExportOptions
|
||||
) => {
|
||||
if (format === "pem") {
|
||||
handlePopUpOpen("certificateCert", { serialNumber });
|
||||
} else if (format === "pkcs12") {
|
||||
if (!currentProject?.slug) return;
|
||||
|
||||
if (!options?.pkcs12?.password || !options?.pkcs12?.alias) {
|
||||
createNotification({
|
||||
text: "Password and alias are required for PKCS12 export",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadCertPkcs12({
|
||||
serialNumber,
|
||||
projectSlug: currentProject.slug,
|
||||
password: options.pkcs12.password,
|
||||
alias: options.pkcs12.alias
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "PKCS12 certificate downloaded successfully",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error: any) {
|
||||
createNotification({
|
||||
text: error?.message || "Failed to download PKCS12 certificate",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
@@ -84,6 +126,11 @@ export const CertificatesSection = () => {
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateExportModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onFormatSelected={handleCertificateExport}
|
||||
/>
|
||||
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
|
||||
@@ -68,6 +68,7 @@ type Props = {
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"certificateCert",
|
||||
"certificateExport",
|
||||
"manageRenewal",
|
||||
"renewCertificate",
|
||||
"managePkiSyncs"
|
||||
@@ -275,7 +276,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificateCert", {
|
||||
handlePopUpOpen("certificateExport", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user