feat: subject alternative name policy enforcement

This commit is contained in:
Sheen Capadngan
2024-08-16 17:24:33 +08:00
parent b9bd518aa6
commit 0961d2f1c6
10 changed files with 106 additions and 46 deletions

View File

@@ -12,6 +12,7 @@ export async function up(knex: Knex): Promise<void> {
tb.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE");
tb.string("name").notNullable();
tb.string("commonName").notNullable();
tb.string("subjectAlternativeName").notNullable();
tb.string("ttl").notNullable();
tb.timestamps(true, true, true);
});

View File

@@ -12,6 +12,7 @@ export const CertificateTemplatesSchema = z.object({
caId: z.string().uuid(),
name: z.string(),
commonName: z.string(),
subjectAlternativeName: z.string(),
ttl: z.string(),
createdAt: z.date(),
updatedAt: z.date()

View File

@@ -12,6 +12,7 @@ const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({
caId: true,
name: true,
commonName: true,
subjectAlternativeName: true,
ttl: true
});
@@ -62,6 +63,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
caId: z.string(),
name: z.string().min(1),
commonName: validateTemplateRegexField,
subjectAlternativeName: validateTemplateRegexField,
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number")
}),
response: {
@@ -95,6 +97,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
caId: z.string().optional(),
name: z.string().min(1).optional(),
commonName: validateTemplateRegexField.optional(),
subjectAlternativeName: validateTemplateRegexField.optional(),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")

View File

@@ -1096,17 +1096,6 @@ export const certificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName,
notBeforeDate,
notAfterDate
},
certificateTemplate
);
}
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
@@ -1136,11 +1125,13 @@ export const certificateAuthorityServiceFactory = ({
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
];
let altNamesArray: {
type: "email" | "dns";
value: string;
}[] = [];
if (altNames) {
const altNamesArray: {
type: "email" | "dns";
value: string;
}[] = altNames
altNamesArray = altNames
.split(",")
.map((name) => name.trim())
.map((altName) => {
@@ -1168,6 +1159,18 @@ export const certificateAuthorityServiceFactory = ({
extensions.push(altNamesExtension);
}
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName,
notBeforeDate,
notAfterDate,
altNames: altNamesArray.map((entry) => entry.value)
},
certificateTemplate
);
}
const serialNumber = crypto.randomBytes(32).toString("hex");
const leafCert = await x509.X509CertificateGenerator.create({
serialNumber,
@@ -1345,17 +1348,6 @@ export const certificateAuthorityServiceFactory = ({
message: "A common name (CN) is required in the CSR or as a parameter to this endpoint"
});
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName: cn,
notBeforeDate,
notAfterDate
},
certificateTemplate
);
}
const { caPrivateKey } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
@@ -1371,11 +1363,12 @@ export const certificateAuthorityServiceFactory = ({
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
];
let altNamesArray: {
type: "email" | "dns";
value: string;
}[] = [];
if (altNames) {
const altNamesArray: {
type: "email" | "dns";
value: string;
}[] = altNames
altNamesArray = altNames
.split(",")
.map((name) => name.trim())
.map((altName) => {
@@ -1403,6 +1396,18 @@ export const certificateAuthorityServiceFactory = ({
extensions.push(altNamesExtension);
}
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName: cn,
notBeforeDate,
notAfterDate,
altNames: altNamesArray.map((entry) => entry.value)
},
certificateTemplate
);
}
const serialNumber = crypto.randomBytes(32).toString("hex");
const leafCert = await x509.X509CertificateGenerator.create({
serialNumber,

View File

@@ -1,5 +1,6 @@
import ms from "ms";
import { TCertificateTemplates } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors";
export const validateCertificateDetailsAgainstTemplate = (
@@ -7,11 +8,9 @@ export const validateCertificateDetailsAgainstTemplate = (
commonName: string;
notBeforeDate: Date;
notAfterDate: Date;
altNames: string[];
},
template: {
commonName: string;
ttl: string;
}
template: TCertificateTemplates
) => {
const commonNameRegex = new RegExp(template.commonName);
if (!commonNameRegex.test(cert.commonName)) {
@@ -25,4 +24,13 @@ export const validateCertificateDetailsAgainstTemplate = (
message: "Invalid validity date based on template policy"
});
}
const subjectAlternativeNameRegex = new RegExp(template.subjectAlternativeName);
cert.altNames.forEach((altName) => {
if (!subjectAlternativeNameRegex.test(altName)) {
throw new BadRequestError({
message: "Invalid subject alternative name based on template policy"
});
}
});
};

View File

@@ -30,6 +30,7 @@ export const certificateTemplateServiceFactory = ({
caId,
name,
commonName,
subjectAlternativeName,
ttl,
actorId,
actorAuthMethod,
@@ -59,6 +60,7 @@ export const certificateTemplateServiceFactory = ({
caId,
name,
commonName,
subjectAlternativeName,
ttl
});
@@ -70,6 +72,7 @@ export const certificateTemplateServiceFactory = ({
caId,
name,
commonName,
subjectAlternativeName,
ttl,
actorId,
actorAuthMethod,
@@ -108,6 +111,7 @@ export const certificateTemplateServiceFactory = ({
const updatedCertTemplate = await certificateTemplateDAL.updateById(certTemplate.id, {
caId,
commonName,
subjectAlternativeName,
name,
ttl
});

View File

@@ -4,6 +4,7 @@ export type TCreateCertTemplateDTO = {
caId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
} & Omit<TProjectPermission, "projectId">;
@@ -12,6 +13,7 @@ export type TUpdateCertTemplateDTO = {
caId?: string;
name?: string;
commonName?: string;
subjectAlternativeName?: string;
ttl?: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -10,6 +10,7 @@ export type TCertificateTemplate = {
caId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
};
@@ -17,6 +18,7 @@ export type TCreateCertificateTemplateDTO = {
caId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
projectId: string;
};
@@ -26,6 +28,7 @@ export type TUpdateCertificateTemplateDTO = {
caId?: string;
name?: string;
commonName?: string;
subjectAlternativeName?: string;
ttl?: string;
projectId: string;
};

View File

@@ -110,6 +110,12 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
}
}, [cert]);
useEffect(() => {
if (!cert && selectedCertTemplate) {
setValue("ttl", selectedCertTemplate.ttl);
}
}, [selectedCertTemplate, cert]);
const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => {
try {
if (!currentWorkspace?.slug) return;

View File

@@ -24,18 +24,21 @@ import {
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
const validateTemplateRegexField = z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
message:
"Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
});
const schema = z.object({
caId: z.string(),
name: z.string().min(1),
commonName: z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
message:
"Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
}),
commonName: validateTemplateRegexField,
subjectAlternativeName: validateTemplateRegexField,
ttl: z.string().trim().min(1)
});
@@ -78,6 +81,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
caId: certTemplate.caId,
name: certTemplate.name,
commonName: certTemplate.commonName,
subjectAlternativeName: certTemplate.subjectAlternativeName,
ttl: certTemplate.ttl
});
} else {
@@ -90,7 +94,13 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
}
}, [certTemplate]);
const onFormSubmit = async ({ caId, name, commonName, ttl }: FormData) => {
const onFormSubmit = async ({
caId,
name,
commonName,
subjectAlternativeName,
ttl
}: FormData) => {
if (!currentWorkspace?.id) {
return;
}
@@ -103,6 +113,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
caId,
name,
commonName,
subjectAlternativeName,
ttl
});
@@ -116,6 +127,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
caId,
name,
commonName,
subjectAlternativeName,
ttl
});
@@ -199,7 +211,22 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="service.acme.com" />
<Input {...field} placeholder=".*\.acme.com" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="subjectAlternativeName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="service\.acme.\..*" />
</FormControl>
)}
/>
@@ -208,7 +235,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) =>
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
label="Max TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired