Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
signatureAlgorithmToAlgCfg
} from "@app/services/certificate-authority/certificate-authority-fns";

import { CertExtendedKeyUsageType, CertKeyUsageType, CertSubjectAlternativeNameType } from "./certificate-constants";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertPolicyState,
CertSubjectAlternativeNameType
} from "./certificate-constants";
import {
bufferToString,
buildCertificateSubjectFromTemplate,
Expand Down Expand Up @@ -202,6 +207,31 @@ export const validateAlgorithmCompatibility = (
}
};

/**
* Validates that a request asking to issue a CA certificate (basicConstraints.isCA) is permitted
* by the policy's CA state, throwing the standard denial error otherwise. Returns the resolved
* policy CA state so callers that need it for further checks (e.g. max path length) don't have
* to re-derive it.
*
* Centralizes the CA-denial check shared by every issuance path (direct issuance, CSR/profile
* issuance, approval finalization, and renewal) so a path can't silently skip it.
*/
export const assertCaIssuancePolicyAllowed = (
policy: { basicConstraints?: { isCA?: string } | null } | null | undefined,
shouldIssueAsCA: boolean
): CertPolicyState => {
const policyIsCAState = (policy?.basicConstraints?.isCA as CertPolicyState) || CertPolicyState.DENIED;

if (shouldIssueAsCA && policyIsCAState === CertPolicyState.DENIED) {
throw new BadRequestError({
message:
"CA certificate issuance is not allowed by this policy. The policy's CA:true basicConstraints must be set to 'allowed' or 'required'."
});
}

return policyIsCAState;
};

/**
* Gets the effective algorithms with fallbacks
*/
Expand Down
11 changes: 2 additions & 9 deletions backend/src/services/certificate-v3/certificate-approval-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
CertPolicyState
} from "../certificate-common/certificate-constants";
import {
assertCaIssuancePolicyAllowed,
calculateFinalRenewBeforeDays,
extractCertificateFromBuffer,
generateSelfSignedCertificate,
Expand Down Expand Up @@ -462,15 +463,7 @@ export const certificateApprovalServiceFactory = (
validateAlgorithmCompatibility(ca, certPolicy);

const csrBasicConstraints = certRequest.basicConstraints as { isCA: boolean; pathLength?: number } | undefined;
const policyIsCAState: CertPolicyState =
(certPolicy.basicConstraints?.isCA as CertPolicyState) || CertPolicyState.DENIED;

if (csrBasicConstraints?.isCA && policyIsCAState === CertPolicyState.DENIED) {
throw new BadRequestError({
message:
"CA certificate issuance is not allowed by the current policy. The policy's CA:true basicConstraints must be set to 'allowed' or 'required'."
});
}
const policyIsCAState = assertCaIssuancePolicyAllowed(certPolicy, csrBasicConstraints?.isCA === true);

let effectiveBasicConstraints = csrBasicConstraints;
const storedPathLength = csrBasicConstraints?.pathLength;
Expand Down
85 changes: 85 additions & 0 deletions backend/src/services/certificate-v3/certificate-v3-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertPolicyState,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
Expand Down Expand Up @@ -2184,6 +2185,90 @@ describe("CertificateV3Service", () => {

expect(result).toHaveProperty("certificate", "renewed-cert");
});

it("should reject renewal of a CA certificate when the policy now denies CA issuance", async () => {
const caCert = { ...mockOriginalCert, isCA: true, pathLength: 0 };

vi.mocked(mockCertificateDAL.findById).mockResolvedValue(caCert);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificatePolicyService.getPolicyById).mockResolvedValue({
...mockPolicy,
basicConstraints: { isCA: CertPolicyState.DENIED }
} as any);
vi.mocked(mockCertificatePolicyService.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});

vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});

await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(/CA certificate issuance is not allowed by this policy/);

expect(mockInternalCaService.issueCertFromCa).not.toHaveBeenCalled();
});

it("should carry the CA basicConstraints and path length through to the renewed certificate", async () => {
const caCert = { ...mockOriginalCert, isCA: true, pathLength: 0 };

vi.mocked(mockCertificateDAL.findById)
.mockResolvedValueOnce(caCert)
.mockResolvedValueOnce({ ...caCert, id: "cert-456", serialNumber: "789012" });
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificatePolicyService.getPolicyById).mockResolvedValue({
...mockPolicy,
basicConstraints: { isCA: CertPolicyState.ALLOWED, maxPathLength: 1 }
} as any);
vi.mocked(mockCertificatePolicyService.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({
certificate: "renewed-ca-cert",
certificateChain: "renewed-chain",
issuingCaCertificate: "issuing-ca",
privateKey: "private-key",
serialNumber: "789012",
certificateId: "cert-456",
commonName: "test.example.com",
ca: mockCA
});

const newCert = { ...caCert, id: "cert-456", serialNumber: "789012" };
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert);

vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});

const result = await service.renewCertificate({
certificateId: "cert-123",
...mockActor
});

expect(result).toHaveProperty("certificate", "renewed-ca-cert");
expect(mockInternalCaService.issueCertFromCa).toHaveBeenCalledWith(
expect.objectContaining({
basicConstraints: { isCA: true, pathLength: 1 },
pathLength: 0
})
);
});
});

describe("updateRenewalConfig", () => {
Expand Down
35 changes: 15 additions & 20 deletions backend/src/services/certificate-v3/certificate-v3-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertPolicyState,
mapExtendedKeyUsageToLegacy,
mapKeyUsageToLegacy,
mapLegacyExtendedKeyUsageToStandard,
Expand All @@ -80,6 +79,7 @@ import {
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import {
assertCaIssuancePolicyAllowed,
calculateFinalRenewBeforeDays,
detectSanType,
extractCertificateFromBuffer,
Expand Down Expand Up @@ -1284,15 +1284,7 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, policy);

const shouldIssueAsCA = certificateRequest.basicConstraints?.isCA === true;
const policyIsCAState: CertPolicyState =
(policy.basicConstraints?.isCA as CertPolicyState) || CertPolicyState.DENIED;

if (shouldIssueAsCA && policyIsCAState === CertPolicyState.DENIED) {
throw new BadRequestError({
message:
"CA certificate issuance is not allowed by this policy. The policy's CA:true basicConstraints must be set to 'allowed' or 'required'."
});
}
assertCaIssuancePolicyAllowed(policy, shouldIssueAsCA);

const caBasicConstraints = shouldIssueAsCA
? { isCA: true, pathLength: policy.basicConstraints?.maxPathLength }
Expand Down Expand Up @@ -1602,15 +1594,7 @@ export const certificateV3ServiceFactory = ({

validateAlgorithmCompatibility(ca, policy);

const policyIsCAState: CertPolicyState =
(policy.basicConstraints?.isCA as CertPolicyState) || CertPolicyState.DENIED;

if (shouldIssueAsCA && policyIsCAState === CertPolicyState.DENIED) {
throw new BadRequestError({
message:
"CA certificate issuance is not allowed by this policy. The policy's CA:true basicConstraints must be set to 'allowed' or 'required'."
});
}
assertCaIssuancePolicyAllowed(policy, shouldIssueAsCA);

const csrApprovalFactory = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.CertRequest](
ApprovalPolicyType.CertRequest
Expand Down Expand Up @@ -2455,6 +2439,10 @@ export const certificateV3ServiceFactory = ({

const ttl = getSimpleTtl(originalCert.notBefore, originalCert.notAfter);

// The renewed certificate must keep the original's CA status (and stay subject to the
// same CA-issuance policy checks every other issuance path runs) — see basicConstraints below.
const shouldIssueAsCA = originalCert.isCA === true;

const certificateRequest = {
commonName: originalCert.commonName || undefined,
organization: originalCert.subjectOrganization || undefined,
Expand All @@ -2471,7 +2459,8 @@ export const certificateV3ServiceFactory = ({
ttl
},
signatureAlgorithm: originalCert.signatureAlgorithm || undefined,
keyAlgorithm: originalCert.keyAlgorithm || undefined
keyAlgorithm: originalCert.keyAlgorithm || undefined,
basicConstraints: shouldIssueAsCA ? { isCA: true, pathLength: originalCert.pathLength ?? undefined } : undefined
};

let validationResult: { isValid: boolean; errors: string[] } = { isValid: true, errors: [] };
Expand All @@ -2496,6 +2485,8 @@ export const certificateV3ServiceFactory = ({
});
}

assertCaIssuancePolicyAllowed(policy, shouldIssueAsCA);

const notBefore = new Date();
const notAfter = new Date(Date.now() + parseTtlToMs(ttl));

Expand Down Expand Up @@ -2550,6 +2541,10 @@ export const certificateV3ServiceFactory = ({
signatureAlgorithm: originalSignatureAlgorithm,
keyAlgorithm: originalKeyAlgorithm,
isFromProfile: true,
basicConstraints: shouldIssueAsCA
? { isCA: true, pathLength: policy?.basicConstraints?.maxPathLength }
: undefined,
pathLength: originalCert.pathLength ?? undefined,
Comment on lines +2544 to +2547

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When originalCert.pathLength is null (a CA issued with unlimited path length, stored as null in the DB) and policy.basicConstraints.maxPathLength is defined (e.g. 2), the call to $createBasicConstraintsExtension sees policyMaxPathLength = 2 and pathLength = undefined, and throws "Path length is required when issuing CA certificates because the policy only allows a maximum path length of 2." This error is confusing during renewal because the user never chose a path length — it was inherited from the original cert. Defaulting to policy?.basicConstraints?.maxPathLength when the original cert has no stored path length would be a natural renewal semantic (the renewed cert gets the policy cap rather than failing).

Suggested change
basicConstraints: shouldIssueAsCA
? { isCA: true, pathLength: policy?.basicConstraints?.maxPathLength }
: undefined,
pathLength: originalCert.pathLength ?? undefined,
basicConstraints: shouldIssueAsCA
? { isCA: true, pathLength: policy?.basicConstraints?.maxPathLength }
: undefined,
pathLength: originalCert.pathLength ?? policy?.basicConstraints?.maxPathLength ?? undefined,

organization: originalCert.subjectOrganization || undefined,
ou: originalCert.subjectOrganizationalUnit || undefined,
country: originalCert.subjectCountry || undefined,
Expand Down