diff --git a/backend/src/db/migrations/20260624120000_add-verify-client-certificate-chain-to-identity-tls-cert-auth.ts b/backend/src/db/migrations/20260624120000_add-verify-client-certificate-chain-to-identity-tls-cert-auth.ts new file mode 100644 index 00000000000..80a44ee53b3 --- /dev/null +++ b/backend/src/db/migrations/20260624120000_add-verify-client-certificate-chain-to-identity-tls-cert-auth.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + const hasColumn = await knex.schema.hasColumn(TableName.IdentityTlsCertAuth, "verifyClientCertificateChain"); + if (hasColumn) { + await knex.schema.alterTable(TableName.IdentityTlsCertAuth, (t) => { + t.dropColumn("verifyClientCertificateChain"); + }); + } +} diff --git a/backend/src/db/schemas/identity-tls-cert-auths.ts b/backend/src/db/schemas/identity-tls-cert-auths.ts index 17af42e0339..8c27263f985 100644 --- a/backend/src/db/schemas/identity-tls-cert-auths.ts +++ b/backend/src/db/schemas/identity-tls-cert-auths.ts @@ -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; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 9797132ac5d..9ec2fd8bce1 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1906,6 +1906,7 @@ interface AddIdentityTlsCertAuthEvent { identityId: string; allowedCommonNames: string | null | undefined; allowedSubjectAltNames: string[] | null | undefined; + verifyClientCertificateChain: boolean; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -1926,6 +1927,7 @@ interface UpdateIdentityTlsCertAuthEvent { identityId: string; allowedCommonNames: string | null | undefined; allowedSubjectAltNames: string[] | null | undefined; + verifyClientCertificateChain?: boolean; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 4e1b2112e47..3aa523c76bd 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -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.", @@ -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.", diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index dbde01e8500..509c4feca6d 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -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() @@ -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[], @@ -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() @@ -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[], diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.test.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.test.ts index 9d26235138a..26d9f978771 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.test.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.test.ts @@ -1,4 +1,7 @@ -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, @@ -6,7 +9,8 @@ import { normalizeAllowedSubjectAltName, parseCertificateSubjectAltNames, parseSubjectDetails, - TCertificateSanItem + TCertificateSanItem, + verifyClientCertificateChain } from "./identity-tls-cert-auth-fns"; describe("parseSubjectDetails", () => { @@ -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 => { + 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 => { + 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" }); + }); +}); diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.ts index d3559470711..8bcba1f41d0 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-fns.ts @@ -1,3 +1,132 @@ +import { crypto } from "@app/lib/crypto/cryptography"; + +type TNativeX509 = InstanceType; + +export type TVerifyClientCertificateChainResult = + | { ok: true } + | { ok: false; reasonCode: "ca_verification_failed" | "certificate_expired" | "certificate_not_yet_valid" }; + +const isWithinValidityWindow = (cert: TNativeX509, at: Date): boolean => + new Date(cert.validFrom) <= at && at <= new Date(cert.validTo); + +/** + * Validate the presented client certificate chain against a configured trust anchor. + * + * Unlike single-hop verification (leaf signed directly by the configured CA), this builds a + * path from the leaf through the presented intermediates up to the configured trust anchor and + * verifies, at every hop: the issuer relationship (subject/issuer match + signature), that each + * issuer is a CA (including the trust anchor itself), and that every certificate on the path is + * within its validity window. + * + * The trust anchor is the only trusted input. Presented intermediates are untrusted: a forged or + * unrelated intermediate cannot create a path to the anchor, so it is rejected. This mirrors how + * SPIFFE consumers (e.g. Envoy, Vault) validate X.509-SVID chains and lets an operator pin a + * stable root while the issuing intermediate rotates underneath it. + * + * NOTE: each hop's issuer/subject match is a string comparison of the OpenSSL-formatted DN strings, + * so this assumes every certificate on the path shares the same PKI-level DN encoding conventions + * (string types and attribute ordering). See `issuedBy` below for the heterogeneous-PKI caveat. + * + * @param leaf the end-entity certificate presented by the client (chain[0]) + * @param presentedChain intermediates presented by the client (chain[1..n]); order-independent + * @param trustAnchor the configured CA certificate to anchor the path on + */ +export const verifyClientCertificateChain = ({ + leaf, + presentedChain, + trustAnchor, + now = new Date() +}: { + leaf: TNativeX509; + presentedChain: TNativeX509[]; + trustAnchor: TNativeX509; + now?: Date; +}): TVerifyClientCertificateChainResult => { + // Candidate issuers the builder may walk through. The trust anchor is always available; the + // presented intermediates are untrusted candidates that only matter if they help reach the anchor. + const anchorRaw = trustAnchor.raw; + const isAnchor = (cert: TNativeX509): boolean => cert.raw.equals(anchorRaw); + + /** + * Returns true when `issuer` issued `child`. + * + * The name check compares the OpenSSL-formatted DN strings returned by Node's X509Certificate + * (`child.issuer === issuer.subject`). This is a fast pre-filter before the cryptographic + * `verify`, and assumes both sides of the chain share the same PKI-level DN encoding + * conventions — i.e. the same string types (PrintableString vs UTF8String) and attribute + * ordering for equivalent names. That holds within a single PKI (SPIRE emits the leaf and the + * rotating intermediate from one CA with consistent encoding), which is the supported case here. + * + * It can yield a false negative in a heterogeneous PKI where the issuer and subject encode the + * same logical DN differently (e.g. one cert uses PrintableString and the other UTF8String for an + * attribute, or they differ in attribute ordering). In that situation a cryptographically valid + * issuer relationship is rejected and chain validation fails with `ca_verification_failed`. A + * full RFC 5280 name comparison (per-RDN, encoding-insensitive) would be required to support that. + */ + const issuedBy = (child: TNativeX509, issuer: TNativeX509): boolean => { + if (child.issuer !== issuer.subject) return false; + try { + return child.verify(issuer.publicKey); + } catch { + return false; + } + }; + + // Depth-first path build from the leaf up to the anchor. Bounded by the number of presented + // intermediates to prevent cycles; each intermediate may be used at most once on a path. + const maxDepth = presentedChain.length + 1; + + const walk = (current: TNativeX509, used: Set, depth: number): TVerifyClientCertificateChainResult => { + if (!isWithinValidityWindow(current, now)) { + return { + ok: false, + reasonCode: now < new Date(current.validFrom) ? "certificate_not_yet_valid" : "certificate_expired" + }; + } + + // Reached the trust anchor: the path is complete and valid. + if (isAnchor(current)) return { ok: true }; + + // The configured anchor directly issued the current certificate. The anchor must itself be a + // CA to sign certificates: presented intermediates are gated on `candidate.ca` below, and the + // trust anchor is held to the same bar so a non-CA cert can't anchor a path. Without this, a + // configured end-entity (CA:FALSE) certificate would still be accepted as the leaf's issuer. + if (trustAnchor.ca && issuedBy(current, trustAnchor)) { + if (!isWithinValidityWindow(trustAnchor, now)) { + return { + ok: false, + reasonCode: now < new Date(trustAnchor.validFrom) ? "certificate_not_yet_valid" : "certificate_expired" + }; + } + return { ok: true }; + } + + if (depth >= maxDepth) return { ok: false, reasonCode: "ca_verification_failed" }; + + // No path to the anchor was found through this node. Default to the generic reason, but + // surface a more specific validity failure if the only viable issuer was rejected for being + // outside its validity window (so an expired/not-yet-valid intermediate is reported as such). + let bestFailure: TVerifyClientCertificateChainResult = { ok: false, reasonCode: "ca_verification_failed" }; + + for (let i = 0; i < presentedChain.length; i += 1) { + const candidate = presentedChain[i]; + // An issuer on the path must be unused on this path, be a CA, and have signed the current + // certificate. + if (!used.has(i) && candidate.ca && issuedBy(current, candidate)) { + const result = walk(candidate, new Set(used).add(i), depth + 1); + if (result.ok) return result; + if (result.reasonCode !== "ca_verification_failed") { + bestFailure = result; + } + } + } + + return bestFailure; + }; + + return walk(leaf, new Set(), 0); +}; + export const parseSubjectDetails = (data?: string | null) => { const values: Record = {}; if (!data) return values; diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 8520b23eb68..96e5a62a574 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -46,7 +46,8 @@ import { isSubjectAltNameAllowed, parseAllowedSubjectAltNames, parseSubjectDetails, - serializeAllowedSubjectAltNames + serializeAllowedSubjectAltNames, + verifyClientCertificateChain } from "./identity-tls-cert-auth-fns"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; @@ -119,7 +120,8 @@ export const identityTlsCertAuthServiceFactory = ({ cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate }).toString(); - const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; + const presentedCertificates = extractX509CertFromChain(decodeURIComponent(clientCertificate)); + const leafCertificate = presentedCertificates?.[0]; if (!leafCertificate) { throw new BadRequestError({ message: "Missing client certificate" }); } @@ -127,18 +129,52 @@ export const identityTlsCertAuthServiceFactory = ({ const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate); const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate); - const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); - if (!isValidCertificate) - throw new UnauthorizedError({ - message: "Access denied: Certificate not issued by the provided CA.", - detail: { - reasonCode: "ca_verification_failed", - identityId: identity.id, - orgId: identity.orgId, - identityName: identity.name - } + if (identityTlsCertAuth.verifyClientCertificateChain) { + // Trust-anchor mode: the configured CA is a trust anchor. Build a path from the presented + // leaf through the presented intermediates up to the anchor (RFC 5280 path validation), + // rather than requiring the anchor to be the leaf's direct issuer. This supports issuers + // that rotate beneath a stable root (e.g. SPIRE X.509-SVID intermediates) by pinning the + // long-lived root while the client presents the current intermediate alongside its leaf. + const presentedChain = presentedCertificates + .slice(1) + .map((pem) => new crypto.nativeCrypto.X509Certificate(pem)); + + const chainResult = verifyClientCertificateChain({ + leaf: clientCertificateX509, + presentedChain, + trustAnchor: caCertificateX509 }); + if (!chainResult.ok) { + const message = + chainResult.reasonCode === "ca_verification_failed" + ? "Access denied: Certificate chain could not be validated against the provided CA." + : "Access denied: A certificate in the chain is outside its validity period."; + throw new UnauthorizedError({ + message, + detail: { + reasonCode: chainResult.reasonCode, + identityId: identity.id, + orgId: identity.orgId, + identityName: identity.name + } + }); + } + } else { + // Single-hop mode (default): the configured CA must be the direct issuer of the leaf. + const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); + if (!isValidCertificate) + throw new UnauthorizedError({ + message: "Access denied: Certificate not issued by the provided CA.", + detail: { + reasonCode: "ca_verification_failed", + identityId: identity.id, + orgId: identity.orgId, + identityName: identity.name + } + }); + } + // Require an end-entity certificate issued by the configured CA, not the CA certificate // itself. `.ca` covers certs marked CA:TRUE; the raw comparison also covers a self-signed CA // that omits basic constraints. @@ -356,7 +392,8 @@ export const identityTlsCertAuthServiceFactory = ({ isActorSuperAdmin, caCertificate, allowedCommonNames, - allowedSubjectAltNames + allowedSubjectAltNames, + verifyClientCertificateChain: verifyClientCertificateChainOpt }) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -443,6 +480,7 @@ export const identityTlsCertAuthServiceFactory = ({ allowedSubjectAltNames: serializeAllowedSubjectAltNames(allowedSubjectAltNames), accessTokenTTL, encryptedCaCertificate: encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob, + verifyClientCertificateChain: verifyClientCertificateChainOpt ?? false, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) }, @@ -458,6 +496,7 @@ export const identityTlsCertAuthServiceFactory = ({ caCertificate, allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain: verifyClientCertificateChainOpt, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, @@ -549,6 +588,7 @@ export const identityTlsCertAuthServiceFactory = ({ encryptedCaCertificate: caCertificate ? encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob : undefined, + verifyClientCertificateChain: verifyClientCertificateChainOpt, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts index e820cb89264..1d6239cb81f 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts @@ -12,6 +12,7 @@ export type TAttachTlsCertAuthDTO = { caCertificate: string; allowedCommonNames?: string | null; allowedSubjectAltNames?: string[] | null; + verifyClientCertificateChain?: boolean; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -24,6 +25,7 @@ export type TUpdateTlsCertAuthDTO = { caCertificate?: string; allowedCommonNames?: string | null; allowedSubjectAltNames?: string[] | null; + verifyClientCertificateChain?: boolean; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; diff --git a/docs/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx index 12409218b04..4f5ff63631d 100644 --- a/docs/documentation/platform/identities/tls-cert-auth.mdx +++ b/docs/documentation/platform/identities/tls-cert-auth.mdx @@ -94,6 +94,7 @@ Now create a new TLS Certificate Auth Method. Here's some information about each field: - **CA Certificate:** A PEM encoded CA Certificate used to validate incoming TLS request client certificate. +- **Verify Client Certificate Chain (default is disabled):** By default, the CA Certificate must be the **direct issuer** of the client's leaf certificate. Enable this to instead treat the CA Certificate as a **trust anchor** and accept any client whose presented chain (leaf followed by intermediates) builds a valid path up to it. This is helpful when the issuing intermediate rotates beneath a long-lived root, such as a **SPIRE X.509-SVID**: you can pin the stable root here once and let the workload present its current intermediate alongside its leaf, rather than updating the CA Certificate on every rotation. Either way the trust anchor is the only certificate trusted, the client certificate must still be an end-entity certificate, and the Common Name and Subject Alternative Name checks below still apply to the leaf. - **Allowed Common Names:** A comma separated list of client certificate common names allowed. - **Allowed Subject Alternative Names:** A comma separated list of client certificate subject alternative names (SANs) allowed. This is useful when the client certificate carries its identity in a SAN rather than the Subject Common Name, such as a SPIFFE X.509-SVID whose identity is a URI SAN (`spiffe://trust-domain/path`) with an empty Subject. Matching is type-aware, so an entry only matches a SAN of the same type: - **Type-prefixed entries** are matched against the corresponding SAN type, e.g. `URI:spiffe://example.org/svc`, `IP:10.0.0.1`, `EMAIL:svc@example.com`. diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index c8fc689f056..0eee524334c 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -802,6 +802,7 @@ export const useAddIdentityTlsCertAuth = () => { identityId, allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain, caCertificate, accessTokenTTL, accessTokenMaxTTL, @@ -815,6 +816,7 @@ export const useAddIdentityTlsCertAuth = () => { { allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain, caCertificate, accessTokenTTL, accessTokenMaxTTL, @@ -854,6 +856,7 @@ export const useUpdateIdentityTlsCertAuth = () => { identityId, allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain, caCertificate, accessTokenTTL, accessTokenMaxTTL, @@ -868,6 +871,7 @@ export const useUpdateIdentityTlsCertAuth = () => { caCertificate, allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index cce02fce659..8375d14a9e4 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -575,6 +575,7 @@ export type IdentityTlsCertAuth = { caCertificate: string; allowedCommonNames: string; allowedSubjectAltNames: string[] | null; + verifyClientCertificateChain: boolean; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -588,6 +589,7 @@ export type AddIdentityTlsCertAuthDTO = { caCertificate: string; allowedCommonNames?: string; allowedSubjectAltNames?: string[]; + verifyClientCertificateChain?: boolean; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -603,6 +605,7 @@ export type UpdateIdentityTlsCertAuthDTO = { caCertificate: string; allowedCommonNames?: string | null; allowedSubjectAltNames?: string[] | null; + verifyClientCertificateChain?: boolean; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx index c790a7aef78..754c53f8cb9 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx @@ -12,6 +12,7 @@ import { FieldGroup, FieldLabel, Input, + Switch, Tabs, TabsContent, TabsList, @@ -48,6 +49,7 @@ const buildSchema = (maxAccessTokenTTL: number) => allowedCommonNames: z.string().optional(), allowedSubjectAltNames: z.string().optional(), caCertificate: z.string().min(1), + verifyClientCertificateChain: z.boolean().default(false), accessTokenTTL: accessTokenTtlSchema(maxAccessTokenTTL, "Access Token TTL"), accessTokenMaxTTL: accessTokenTtlSchema(maxAccessTokenTTL, "Access Token Max TTL"), accessTokenNumUsesLimit: z.string(), @@ -106,6 +108,7 @@ export const IdentityTlsCertAuthForm = ({ resolver, defaultValues: { caCertificate: "", + verifyClientCertificateChain: false, accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "", @@ -119,6 +122,7 @@ export const IdentityTlsCertAuthForm = ({ caCertificate: data.caCertificate, allowedCommonNames: data.allowedCommonNames || undefined, allowedSubjectAltNames: data.allowedSubjectAltNames?.join("\n") || undefined, + verifyClientCertificateChain: data.verifyClientCertificateChain ?? false, accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), accessTokenNumUsesLimit: data.accessTokenNumUsesLimit @@ -129,6 +133,7 @@ export const IdentityTlsCertAuthForm = ({ } else { reset({ caCertificate: "", + verifyClientCertificateChain: false, accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "", @@ -145,6 +150,7 @@ export const IdentityTlsCertAuthForm = ({ caCertificate, allowedCommonNames, allowedSubjectAltNames, + verifyClientCertificateChain, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, @@ -167,6 +173,7 @@ export const IdentityTlsCertAuthForm = ({ allowedSubjectAltNames: allowedSubjectAltNamesList.length ? allowedSubjectAltNamesList : null, + verifyClientCertificateChain, identityId, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), @@ -182,6 +189,7 @@ export const IdentityTlsCertAuthForm = ({ allowedSubjectAltNames: allowedSubjectAltNamesList.length ? allowedSubjectAltNamesList : undefined, + verifyClientCertificateChain, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit || "0"), @@ -300,6 +308,39 @@ export const IdentityTlsCertAuthForm = ({ + ( + +
+ + Verify Client Certificate Chain + + + + + + When disabled, the CA certificate must be the direct issuer of the + client's leaf certificate. When enabled, 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. + + + + +
+
+ )} + /> {data.allowedSubjectAltNames?.join(", ")} + + {data.verifyClientCertificateChain ? "Enabled" : "Disabled"} + ); };