Skip to content

Commit

Permalink
Add cert support for alt names
Browse files Browse the repository at this point in the history
  • Loading branch information
dangtony98 committed Jun 24, 2024
1 parent f9a1acc commit b5aa650
Show file tree
Hide file tree
Showing 12 changed files with 134 additions and 13 deletions.
24 changes: 24 additions & 0 deletions backend/src/db/migrations/20240624163425_certificate-alt-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.Certificate)) {
const hasAltNamesColumn = await knex.schema.hasColumn(TableName.Certificate, "altNames");
if (!hasAltNamesColumn) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.string("altNames").defaultTo("");
});
}
}
}

export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.Certificate)) {
if (await knex.schema.hasColumn(TableName.Certificate, "altNames")) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.dropColumn("altNames");
});
}
}
}
3 changes: 2 additions & 1 deletion backend/src/db/schemas/certificates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const CertificatesSchema = z.object({
notBefore: z.date(),
notAfter: z.date(),
revokedAt: z.date().nullable().optional(),
revocationReason: z.number().nullable().optional()
revocationReason: z.number().nullable().optional(),
altNames: z.string().default("").nullable().optional()
});

export type TCertificates = z.infer<typeof CertificatesSchema>;
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/schemas/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const ProjectsSchema = z.object({
updatedAt: z.date(),
version: z.number().default(1),
upgradeStatus: z.string().nullable().optional(),
kmsCertificateKeyId: z.string().uuid().nullable().optional(),
pitVersionLimit: z.number().default(10)
pitVersionLimit: z.number().default(10),
kmsCertificateKeyId: z.string().uuid().nullable().optional()
});

export type TProjects = z.infer<typeof ProjectsSchema>;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/lib/api-docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,8 @@ export const CERTIFICATE_AUTHORITIES = {
caId: "The ID of the CA to issue the certificate from",
friendlyName: "A friendly name for the certificate",
commonName: "The common name (CN) for the certificate",
altNames:
"A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.",
ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...",
notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format",
notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format",
Expand Down
6 changes: 5 additions & 1 deletion backend/src/server/routes/v1/certificate-authority-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import {
validateAltNamesField,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";

export const registerCaRouter = async (server: FastifyZodProvider) => {
server.route({
Expand Down Expand Up @@ -452,6 +455,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
.object({
friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName),
commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName),
altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import crypto, { KeyObject } from "crypto";
import ms from "ms";
import { z } from "zod";

import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
TSignIntermediateDTO,
TUpdateCaDTO
} from "./certificate-authority-types";
import { hostnameRegex } from "./certificate-authority-validators";

type TCertificateAuthorityServiceFactoryDep = {
certificateAuthorityDAL: Pick<
Expand Down Expand Up @@ -653,6 +655,7 @@ export const certificateAuthorityServiceFactory = ({
caId,
friendlyName,
commonName,
altNames,
ttl,
notBefore,
notAfter,
Expand Down Expand Up @@ -738,6 +741,46 @@ export const certificateAuthorityServiceFactory = ({
kmsService
});

const extensions: x509.Extension[] = [
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true),
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
];

if (altNames) {
const altNamesArray: {
type: "email" | "dns";
value: string;
}[] = altNames
.split(",")
.map((name) => name.trim())
.map((altName) => {
// check if the altName is a valid email
if (z.string().email().safeParse(altName).success) {
return {
type: "email",
value: altName
};
}

// check if the altName is a valid hostname
if (hostnameRegex.test(altName)) {
return {
type: "dns",
value: altName
};
}

// If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly
throw new Error(`Invalid altName: ${altName}`);
});

console.log("the altNamesArray: ", altNamesArray);

Check warning on line 779 in backend/src/services/certificate-authority/certificate-authority-service.ts

View workflow job for this annotation

GitHub Actions / Check TS and Lint

Unexpected console statement
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
extensions.push(altNamesExtension);
}

const serialNumber = crypto.randomBytes(32).toString("hex");
const leafCert = await x509.X509CertificateGenerator.create({
serialNumber,
Expand All @@ -748,12 +791,7 @@ export const certificateAuthorityServiceFactory = ({
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true),
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
]
extensions
});

const skLeafObj = KeyObject.from(leafKeys.privateKey);
Expand All @@ -771,6 +809,7 @@ export const certificateAuthorityServiceFactory = ({
status: CertStatus.ACTIVE,
friendlyName: friendlyName || commonName,
commonName,
altNames,
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type TIssueCertFromCaDTO = {
caId: string;
friendlyName?: string;
commonName: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,29 @@ const isValidDate = (dateString: string) => {
};

export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" });

export const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/;
export const validateAltNamesField = z
.string()
.trim()
.default("")
.transform((data) => {
if (data === "") return "";
// Trim each alt name and join with ', ' to ensure formatting
return data
.split(",")
.map((id) => id.trim())
.join(", ");
})
.refine(
(data) => {
if (data === "") return true;
// Split and validate each alt name
return data.split(", ").every((name) => {
return hostnameRegex.test(name) || z.string().email().safeParse(name).success;
});
},
{
message: "Each alt name must be a valid hostname or email address"
}
);
4 changes: 2 additions & 2 deletions docs/documentation/platform/pki/certificates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ In the following steps, we explore how to issue a X.509 certificate under a CA.

- Issuing CA: The CA under which to issue the certificate.
- Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty.
- Common Name (CN): The (common) name of the certificate.
- Common Name (CN): The (common) name for the certificate like `service.acme.com`.
- Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.
- TTL: The lifetime of the certificate in seconds.
- Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`.

</Step>
<Step title="Copying the certificate details">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/api/ca/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type TCreateCertificateDTO = {
caId: string;
friendlyName?: string;
commonName: string;
altNames: string; // sans
ttl: string; // string compatible with ms
notBefore?: string;
notAfter?: string;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/api/certificates/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type TCertificate = {
status: CertStatus;
friendlyName: string;
commonName: string;
altNames: string;
serialNumber: string;
notBefore: string;
notAfter: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const schema = z.object({
caId: z.string(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
altNames: z.string(),
ttl: z.string().trim()
});

Expand Down Expand Up @@ -71,19 +72,21 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName,
altNames: cert.altNames,
ttl: ""
});
} else {
reset({
caId: "",
friendlyName: "",
commonName: "",
altNames: "",
ttl: ""
});
}
}, [cert]);

const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => {
const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => {
try {
if (!currentWorkspace?.slug) return;

Expand All @@ -92,6 +95,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
caId,
friendlyName,
commonName,
altNames,
ttl
});

Expand Down Expand Up @@ -192,6 +196,24 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="altNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="app1.acme.com, app2.acme.com, ..."
isDisabled={Boolean(cert)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
Expand Down

0 comments on commit b5aa650

Please sign in to comment.