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
@@ -0,0 +1,24 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn(TableName.IdentityTlsCertAuth, "verifyClientCertificateChain");
if (!hasColumn) {
await knex.schema.alterTable(TableName.IdentityTlsCertAuth, (t) => {
// When false (default) the configured CA must be the direct issuer of the presented
// leaf certificate (single-hop). When true, the configured CA is treated as a trust
// anchor and the presented client chain (leaf + intermediates) is validated up to it.
t.boolean("verifyClientCertificateChain").notNullable().defaultTo(false);
});
}
}

export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn(TableName.IdentityTlsCertAuth, "verifyClientCertificateChain");
if (hasColumn) {
await knex.schema.alterTable(TableName.IdentityTlsCertAuth, (t) => {
t.dropColumn("verifyClientCertificateChain");
});
}
}
3 changes: 2 additions & 1 deletion backend/src/db/schemas/identity-tls-cert-auths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export const IdentityTlsCertAuthsSchema = z.object({
identityId: z.string().uuid(),
allowedCommonNames: z.string().nullable().optional(),
encryptedCaCertificate: zodBuffer,
allowedSubjectAltNames: z.string().nullable().optional()
allowedSubjectAltNames: z.string().nullable().optional(),
verifyClientCertificateChain: z.boolean().default(false)
});

export type TIdentityTlsCertAuths = z.infer<typeof IdentityTlsCertAuthsSchema>;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1906,6 +1906,7 @@ interface AddIdentityTlsCertAuthEvent {
identityId: string;
allowedCommonNames: string | null | undefined;
allowedSubjectAltNames: string[] | null | undefined;
verifyClientCertificateChain: boolean;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
Expand All @@ -1926,6 +1927,7 @@ interface UpdateIdentityTlsCertAuthEvent {
identityId: string;
allowedCommonNames: string | null | undefined;
allowedSubjectAltNames: string[] | null | undefined;
verifyClientCertificateChain?: boolean;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/lib/api-docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ export const TLS_CERT_AUTH = {
allowedSubjectAltNames:
"The comma-separated list of trusted subject alternative names that are allowed to authenticate with Infisical. Prefix entries by type (URI:, DNS:, IP:, EMAIL:). Bare entries are treated as DNS names.",
caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
verifyClientCertificateChain:
"When false (default), the CA certificate must be the direct issuer of the client's leaf certificate. When true, the CA certificate is treated as a trust anchor and the client-presented chain (leaf plus intermediates) is validated up to it, supporting issuers that rotate beneath a stable root such as SPIRE X.509-SVIDs.",
accessTokenTTL: "The lifetime for an access token in seconds.",
accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.",
Expand All @@ -433,6 +435,8 @@ export const TLS_CERT_AUTH = {
allowedSubjectAltNames:
"The comma-separated list of trusted subject alternative names that are allowed to authenticate with Infisical. Prefix entries by type (URI:, DNS:, IP:, EMAIL:). Bare entries are treated as DNS names.",
caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
verifyClientCertificateChain:
"When false (default), the CA certificate must be the direct issuer of the client's leaf certificate. When true, the CA certificate is treated as a trust anchor and the client-presented chain (leaf plus intermediates) is validated up to it, supporting issuers that rotate beneath a stable root such as SPIRE X.509-SVIDs.",
accessTokenTTL: "The new lifetime for an access token in seconds.",
accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
.max(10240)
.refine(validateCaCertificate, "Invalid CA Certificate.")
.describe(TLS_CERT_AUTH.ATTACH.caCertificate),
verifyClientCertificateChain: z
.boolean()
.default(false)
.describe(TLS_CERT_AUTH.ATTACH.verifyClientCertificateChain),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
Expand Down Expand Up @@ -287,6 +291,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
identityId: identityTlsCertAuth.identityId,
allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
allowedSubjectAltNames: tlsCertAuthResponse.allowedSubjectAltNames,
verifyClientCertificateChain: identityTlsCertAuth.verifyClientCertificateChain,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
Expand Down Expand Up @@ -343,6 +348,10 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
.refine(validateCaCertificate, "Invalid CA Certificate.")
.optional()
.describe(TLS_CERT_AUTH.UPDATE.caCertificate),
verifyClientCertificateChain: z
.boolean()
.optional()
.describe(TLS_CERT_AUTH.UPDATE.verifyClientCertificateChain),
allowedCommonNames: validateCommonNames
.optional()
.nullable()
Expand Down Expand Up @@ -411,6 +420,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
identityId: identityTlsCertAuth.identityId,
allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
allowedSubjectAltNames: tlsCertAuthResponse.allowedSubjectAltNames,
verifyClientCertificateChain: identityTlsCertAuth.verifyClientCertificateChain,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { describe, expect, test } from "vitest";
import crypto from "node:crypto";

import * as x509 from "@peculiar/x509";
import { beforeAll, describe, expect, test } from "vitest";

import {
isSubjectAltNameAllowed,
isValidAllowedSubjectAltNameEntry,
normalizeAllowedSubjectAltName,
parseCertificateSubjectAltNames,
parseSubjectDetails,
TCertificateSanItem
TCertificateSanItem,
verifyClientCertificateChain
} from "./identity-tls-cert-auth-fns";

describe("parseSubjectDetails", () => {
Expand Down Expand Up @@ -234,3 +238,198 @@ describe("isSubjectAltNameAllowed", () => {
).toBe(true);
});
});

describe("verifyClientCertificateChain", () => {
const alg: RsaHashedKeyGenParams = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
};

x509.cryptoProvider.set(crypto.webcrypto as Crypto);

type TIssued = { cert: x509.X509Certificate; keys: CryptoKeyPair };

const NOW = new Date("2026-06-24T12:00:00Z");
const NOT_BEFORE = new Date("2026-06-01T00:00:00Z");
const FAR_FUTURE = new Date("2030-01-01T00:00:00Z");

const toNative = (cert: x509.X509Certificate) => new crypto.X509Certificate(Buffer.from(cert.rawData));

const makeRoot = async (name: string): Promise<TIssued> => {
const keys = await crypto.webcrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const cert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: "01",
name: `CN=${name}`,
notBefore: NOT_BEFORE,
notAfter: FAR_FUTURE,
keys,
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)]
});
return { cert, keys };
};

const makeIntermediate = async (name: string, issuer: TIssued, opts?: { notAfter?: Date }): Promise<TIssued> => {
const keys = await crypto.webcrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const cert = await x509.X509CertificateGenerator.create({
serialNumber: "02",
subject: `CN=${name}`,
issuer: issuer.cert.subject,
notBefore: NOT_BEFORE,
notAfter: opts?.notAfter ?? FAR_FUTURE,
signingKey: issuer.keys.privateKey,
publicKey: keys.publicKey,
signingAlgorithm: alg,
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)]
});
return { cert, keys };
};

const makeLeaf = async (name: string, issuer: TIssued, opts?: { notBefore?: Date; notAfter?: Date }) => {
const keys = await crypto.webcrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const cert = await x509.X509CertificateGenerator.create({
serialNumber: "03",
subject: `CN=${name}`,
issuer: issuer.cert.subject,
notBefore: opts?.notBefore ?? NOT_BEFORE,
notAfter: opts?.notAfter ?? FAR_FUTURE,
signingKey: issuer.keys.privateKey,
publicKey: keys.publicKey,
signingAlgorithm: alg,
extensions: [new x509.BasicConstraintsExtension(false)]
});
return { cert, keys };
};

let root: TIssued;
let intermediate: TIssued;
let otherRoot: TIssued;

beforeAll(async () => {
root = await makeRoot("Stable Root CA");
intermediate = await makeIntermediate("Rotating Intermediate CA", root);
otherRoot = await makeRoot("Unrelated Root CA");
});

test("accepts a leaf whose presented intermediate chains to the configured root", async () => {
const leaf = await makeLeaf("workload", intermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(intermediate.cert)],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: true });
});

test("accepts a leaf issued directly by the configured anchor (single intermediate as anchor)", async () => {
const leaf = await makeLeaf("workload", intermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [],
trustAnchor: toNative(intermediate.cert),
now: NOW
});
expect(result).toEqual({ ok: true });
});

test("rejects when the intermediate is missing (cannot reach the anchor)", async () => {
const leaf = await makeLeaf("workload", intermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "ca_verification_failed" });
});

test("rejects a chain that does not lead to the configured anchor", async () => {
const leaf = await makeLeaf("workload", intermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(intermediate.cert)],
trustAnchor: toNative(otherRoot.cert),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "ca_verification_failed" });
});

test("ignores an unrelated forged intermediate presented alongside the valid one", async () => {
const forged = await makeIntermediate("Forged Intermediate", otherRoot);
const leaf = await makeLeaf("workload", intermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(forged.cert), toNative(intermediate.cert)],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: true });
});

test("rejects an expired leaf", async () => {
const leaf = await makeLeaf("workload", intermediate, {
notBefore: NOT_BEFORE,
notAfter: new Date("2026-06-10T00:00:00Z")
});
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(intermediate.cert)],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "certificate_expired" });
});

test("rejects a not-yet-valid leaf", async () => {
const leaf = await makeLeaf("workload", intermediate, {
notBefore: new Date("2026-07-01T00:00:00Z"),
notAfter: FAR_FUTURE
});
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(intermediate.cert)],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "certificate_not_yet_valid" });
});

test("rejects a leaf signed directly by a non-CA configured anchor", async () => {
// A configured certificate that is not marked CA:TRUE must not anchor a path even when it
// cryptographically signed the leaf. Otherwise chain mode would authenticate against a non-CA
// issuer despite being documented as trust-anchor (CA) validation.
const nonCaAnchorKeys = await crypto.webcrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const nonCaAnchor = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: "0a",
name: "CN=Non-CA Anchor",
notBefore: NOT_BEFORE,
notAfter: FAR_FUTURE,
keys: nonCaAnchorKeys,
extensions: [new x509.BasicConstraintsExtension(false)]
});
const leaf = await makeLeaf("workload", { cert: nonCaAnchor, keys: nonCaAnchorKeys });
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [],
trustAnchor: toNative(nonCaAnchor),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "ca_verification_failed" });
});

test("rejects when the intermediate has expired", async () => {
const expiredIntermediate = await makeIntermediate("Expired Intermediate", root, {
notAfter: new Date("2026-06-10T00:00:00Z")
});
const leaf = await makeLeaf("workload", expiredIntermediate);
const result = verifyClientCertificateChain({
leaf: toNative(leaf.cert),
presentedChain: [toNative(expiredIntermediate.cert)],
trustAnchor: toNative(root.cert),
now: NOW
});
expect(result).toEqual({ ok: false, reasonCode: "certificate_expired" });
});
});
Loading