Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Certificate Support for Alt Names (SANs) #2014

Merged
merged 3 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,45 @@ 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}`);
});

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 +790,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 +808,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 like `app1.acme.com, app2.acme.com`.
- 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
Loading