Skip to content

fix(pki): honor CA-issuance policy denial on certificate renewal#7027

Open
Abdul-Moiz31 wants to merge 1 commit into
Infisical:mainfrom
Abdul-Moiz31:fix/pki-ca-renewal-policy-6971
Open

fix(pki): honor CA-issuance policy denial on certificate renewal#7027
Abdul-Moiz31 wants to merge 1 commit into
Infisical:mainfrom
Abdul-Moiz31:fix/pki-ca-renewal-policy-6971

Conversation

@Abdul-Moiz31

Copy link
Copy Markdown

Context

Fixes #6971.

Certificate renewal in certificateV3Service.renewCertificate runs the same certificatePolicyService.validateCertificateRequest that direct issuance runs, but the certificateRequest it built never populated basicConstraints. validateRequestAgainstPolicy's CA-related checks are gated on request.basicConstraints?.isCA === true, which was always falsy on renewal — so a policy that denies CA issuance was silently bypassed when renewing a previously-issued CA certificate.

Separately, even once validation is fixed, the renewal's call to internalCaService.issueCertFromCa never passed basicConstraints/pathLength at all — so the renewed certificate would have lost its CA extension regardless of what the policy said.

Repro from the issue:

  1. Create a policy with CA issuance allowed.
  2. Issue a CA cert under that policy.
  3. Tighten the policy to deny CA issuance.
  4. Renew the existing CA cert → previously succeeded and silently re-issued a CA-flagged cert.

Fix

  • renewCertificate now derives shouldIssueAsCA from originalCert.isCA and populates certificateRequest.basicConstraints (with originalCert.pathLength) before the generic policy validation runs.
  • Added an explicit assertCaIssuancePolicyAllowed(policy, shouldIssueAsCA) guard right after that validation — the same defense-in-depth check the other three issuance paths (direct issuance, CSR/profile issuance, approval finalization) already run.
  • The renewal's issueCertFromCa call now passes basicConstraints: { isCA: true, pathLength: policy?.basicConstraints?.maxPathLength } and pathLength: originalCert.pathLength, so a renewed CA certificate keeps its CA status and gets re-validated against the current policy's max path length and the issuing CA's own constraints — not just blindly copied forward.

Refactor (per the issue's suggestion)

The CA-denial check existed as three near-identical inline copies in certificate-v3-service.ts (direct issuance, CSR/profile issuance) and certificate-approval-fns.ts (approval finalization). Extracted them into a single assertCaIssuancePolicyAllowed(policy, shouldIssueAsCA) helper in certificate-issuance-utils.ts, used by all four issuance paths now (including renewal). This also normalizes the error message, which previously differed slightly between the approval path and the other two.

Tests

Added two unit tests to certificate-v3-service.test.ts:

  • Renewing a CA certificate under a policy that now denies CA issuance throws, and issueCertFromCa is never called.
  • Renewing a CA certificate under a policy that allows it correctly passes basicConstraints/pathLength through to issueCertFromCa.

npx vitest run -c vitest.unit.config.mts src/services/certificate → 198/198 passing. Type-check and lint are clean for all changed files (verified against the project's pre-existing, unrelated lint/type-check debt — error counts are identical before/after this diff).

Type

  • Fix

Checklist

  • Title follows the conventional commit format
  • Tested locally (unit tests added)
  • Read the contributing guide

Certificate renewal built its certificateRequest without basicConstraints,
so validateRequestAgainstPolicy's CA-denial check (gated on
request.basicConstraints?.isCA) was always falsy on renewal. A policy
that denied CA issuance was silently bypassed when renewing a
previously-issued CA certificate, and the renewed cert was issued to
internalCaService.issueCertFromCa without basicConstraints/pathLength
at all, so it would have lost its CA extension regardless.

Renewal now derives shouldIssueAsCA from originalCert.isCA, populates
certificateRequest.basicConstraints for policy validation, runs the same
explicit CA-denial guard the other three issuance paths run, and passes
basicConstraints/pathLength through to issueCertFromCa so the renewed
certificate keeps its original CA status (re-validated against the
current policy's max path length and the issuing CA's own constraints).

Also extracts the CA-denial check duplicated identically across direct
issuance, CSR/profile issuance, and approval finalization into a single
assertCaIssuancePolicyAllowed helper in certificate-issuance-utils.ts,
so renewal (and any future issuance path) reuses the same guard instead
of risking another inline copy that misses it.
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two bugs in renewCertificate: a silent CA-issuance policy bypass (the certificateRequest never had basicConstraints populated, so validateCertificateRequest's CA checks were always skipped), and loss of the CA extension on renewal (because issueCertFromCa never received basicConstraints/pathLength). The refactor extracting the triplicated inline CA-denial check into assertCaIssuancePolicyAllowed is clean, and two new unit tests cover the denial and pass-through paths.

Confidence Score: 4/5

Safe to merge. The core bugs (policy bypass on renewal, lost CA extension on renewal) are correctly fixed with defence-in-depth guards and the behaviour is now consistent with the three other issuance paths.

The fix is well-targeted and well-tested. The one edge case worth watching is renewal of a CA cert whose stored pathLength is null (unlimited) under a policy that now sets maxPathLength: the current code passes pathLength: undefined to issueCertFromCa, which causes the internal helper to throw a confusing error that doesn't explain the root mismatch. This is non-blocking but could confuse operators encountering it in the field.

The renewCertificate function in certificate-v3-service.ts around the issueCertFromCa call — specifically the pathLength: originalCert.pathLength ?? undefined expression when the original cert has no stored path length.

Important Files Changed

Filename Overview
backend/src/services/certificate-common/certificate-issuance-utils.ts New assertCaIssuancePolicyAllowed helper correctly centralizes the CA-denial check; return type accurately reflects the resolved policy state, though callers in the service layer ignore it.
backend/src/services/certificate-v3/certificate-v3-service.ts Core fix: renewCertificate now populates basicConstraints in certificateRequest, calls assertCaIssuancePolicyAllowed, and passes basicConstraints/pathLength through to issueCertFromCa; a null original pathLength under a policy with maxPathLength yields a confusing error (see comment).
backend/src/services/certificate-v3/certificate-approval-fns.ts Refactored to use shared assertCaIssuancePolicyAllowed; return value correctly captured for subsequent path-length logic; behavior unchanged from previous inline check.
backend/src/services/certificate-v3/certificate-v3-service.test.ts Two well-structured unit tests added: one verifying the denial path stops before issueCertFromCa, one verifying the allowed path threads basicConstraints/pathLength through correctly.

Reviews (1): Last reviewed commit: "fix(pki): honor CA-issuance policy denia..." | Re-trigger Greptile

Comment on lines +2544 to +2547
basicConstraints: shouldIssueAsCA
? { isCA: true, pathLength: policy?.basicConstraints?.maxPathLength }
: undefined,
pathLength: originalCert.pathLength ?? undefined,

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,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Certificate renewal bypasses CA-issuance policy denial

1 participant