mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 08:27:22 +00:00
Merge pull request #4920 from Infisical/PKI-51-internal-ca-do-not-require-cn
[PKI-51] Internal CA do not require CN when issuing cert
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
Feature: Internal CA
|
||||||
|
|
||||||
|
Scenario: CSR with SANs only
|
||||||
|
Given I have an ACME cert profile as "acme_profile"
|
||||||
|
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
|
||||||
|
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||||
|
When I create certificate signing request as csr
|
||||||
|
Then I add names to certificate signing request csr
|
||||||
|
"""
|
||||||
|
{}
|
||||||
|
"""
|
||||||
|
And I add subject alternative name to certificate signing request csr
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
"localhost"
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
And I create a RSA private key pair as cert_key
|
||||||
|
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||||
|
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||||
|
And I select challenge with type http-01 for domain localhost from order in order as challenge
|
||||||
|
And I serve challenge response for challenge at localhost
|
||||||
|
And I tell ACME server that challenge is ready to be verified
|
||||||
|
And I poll and finalize the ACME order order as finalized_order
|
||||||
|
And the value finalized_order.body with jq ".status" should be equal to "valid"
|
||||||
|
And I parse the full-chain certificate from order finalized_order as cert
|
||||||
|
And the value cert with jq ".subject.common_name" should be equal to null
|
||||||
|
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
"localhost"
|
||||||
|
]
|
||||||
|
"""
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { isPrivateIp } from "@app/lib/ip/ipRange";
|
import { isPrivateIp } from "@app/lib/ip/ipRange";
|
||||||
@@ -13,10 +15,6 @@ import {
|
|||||||
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
|
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
|
||||||
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||||
|
|
||||||
type FetchError = Error & {
|
|
||||||
code?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TPkiAcmeChallengeServiceFactoryDep = {
|
type TPkiAcmeChallengeServiceFactoryDep = {
|
||||||
acmeChallengeDAL: Pick<
|
acmeChallengeDAL: Pick<
|
||||||
TPkiAcmeChallengeDALFactory,
|
TPkiAcmeChallengeDALFactory,
|
||||||
@@ -74,18 +72,20 @@ export const pkiAcmeChallengeServiceFactory = ({
|
|||||||
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
||||||
// a long running operation for long time. But assuming we are not performing a tons of
|
// a long running operation for long time. But assuming we are not performing a tons of
|
||||||
// challenge validation at the same time, it should be fine.
|
// challenge validation at the same time, it should be fine.
|
||||||
const challengeResponse = await fetch(challengeUrl, {
|
const challengeResponse = await axios.get<string>(challengeUrl.toString(), {
|
||||||
// In case if we override the host in the development mode, still provide the original host in the header
|
// In case if we override the host in the development mode, still provide the original host in the header
|
||||||
// to help the upstream server to validate the request
|
// to help the upstream server to validate the request
|
||||||
headers: { Host: host },
|
headers: { Host: challenge.auth.identifierValue },
|
||||||
signal: AbortSignal.timeout(timeoutMs)
|
timeout: timeoutMs,
|
||||||
|
responseType: "text",
|
||||||
|
validateStatus: () => true
|
||||||
});
|
});
|
||||||
if (challengeResponse.status !== 200) {
|
if (challengeResponse.status !== 200) {
|
||||||
throw new AcmeIncorrectResponseError({
|
throw new AcmeIncorrectResponseError({
|
||||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const challengeResponseBody = await challengeResponse.text();
|
const challengeResponseBody: string = challengeResponse.data;
|
||||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||||
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
||||||
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
||||||
@@ -96,35 +96,25 @@ export const pkiAcmeChallengeServiceFactory = ({
|
|||||||
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now
|
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now
|
||||||
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
|
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
|
||||||
// Properly type and inspect the error
|
// Properly type and inspect the error
|
||||||
if (exp instanceof TypeError && exp.message.includes("fetch failed")) {
|
if (axios.isAxiosError(exp)) {
|
||||||
const { cause } = exp;
|
const axiosError = exp as AxiosError;
|
||||||
let errors: Error[] = [];
|
const errorCode = axiosError.code;
|
||||||
if (cause instanceof AggregateError) {
|
const errorMessage = axiosError.message;
|
||||||
errors = cause.errors as Error[];
|
|
||||||
} else if (cause instanceof Error) {
|
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
|
||||||
errors = [cause];
|
return new AcmeConnectionError({ message: "Connection refused" });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-unreachable-loop
|
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
|
||||||
for (const err of errors) {
|
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||||
// TODO: handle multiple errors, return a compound error instead of just the first error
|
|
||||||
const fetchError = err as FetchError;
|
|
||||||
if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) {
|
|
||||||
return new AcmeConnectionError({ message: "Connection refused" });
|
|
||||||
}
|
|
||||||
if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) {
|
|
||||||
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
|
||||||
}
|
|
||||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
|
||||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
|
||||||
}
|
}
|
||||||
} else if (exp instanceof DOMException) {
|
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
|
||||||
if (exp.name === "TimeoutError") {
|
|
||||||
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
||||||
return new AcmeConnectionError({ message: "Connection timed out" });
|
return new AcmeConnectionError({ message: "Connection timed out" });
|
||||||
}
|
}
|
||||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||||
} else if (exp instanceof Error) {
|
}
|
||||||
|
if (exp instanceof Error) {
|
||||||
logger.error(exp, "Error validating ACME challenge response");
|
logger.error(exp, "Error validating ACME challenge response");
|
||||||
} else {
|
} else {
|
||||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||||
|
|||||||
@@ -703,9 +703,6 @@ export const pkiAcmeServiceFactory = ({
|
|||||||
|
|
||||||
// Check and validate the CSR
|
// Check and validate the CSR
|
||||||
const certificateRequest = extractCertificateRequestFromCSR(csr);
|
const certificateRequest = extractCertificateRequestFromCSR(csr);
|
||||||
if (!certificateRequest.commonName) {
|
|
||||||
throw new AcmeBadCSRError({ message: "Invalid CSR: Common name is required" });
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
certificateRequest.subjectAlternativeNames?.some(
|
certificateRequest.subjectAlternativeNames?.some(
|
||||||
(san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME
|
(san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME
|
||||||
@@ -721,7 +718,7 @@ export const pkiAcmeServiceFactory = ({
|
|||||||
const csrIdentifierValues = new Set(
|
const csrIdentifierValues = new Set(
|
||||||
(certificateRequest.subjectAlternativeNames ?? [])
|
(certificateRequest.subjectAlternativeNames ?? [])
|
||||||
.map((san) => san.value.toLowerCase())
|
.map((san) => san.value.toLowerCase())
|
||||||
.concat([certificateRequest.commonName.toLowerCase()])
|
.concat(certificateRequest.commonName ? [certificateRequest.commonName.toLowerCase()] : [])
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length ||
|
csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length ||
|
||||||
|
|||||||
+1
-6
@@ -1716,12 +1716,7 @@ export const internalCertificateAuthorityServiceFactory = ({
|
|||||||
const csrObj = new x509.Pkcs10CertificateRequest(csr);
|
const csrObj = new x509.Pkcs10CertificateRequest(csr);
|
||||||
|
|
||||||
const dn = parseDistinguishedName(csrObj.subject);
|
const dn = parseDistinguishedName(csrObj.subject);
|
||||||
const cn = commonName || dn.commonName;
|
const cn = (commonName || dn.commonName) ?? "";
|
||||||
|
|
||||||
if (!cn)
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: "A common name (CN) is required in the CSR or as a parameter to this endpoint"
|
|
||||||
});
|
|
||||||
|
|
||||||
const { caPrivateKey, caSecret } = await getCaCredentials({
|
const { caPrivateKey, caSecret } = await getCaCredentials({
|
||||||
caId: ca.id,
|
caId: ca.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user