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
14 changes: 13 additions & 1 deletion src/x509_cert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export class X509Certificate extends PemData<Certificate> implements IPublicKeyC
*/
#publicKey?: PublicKey;

/**
* Thumbprints cache
*/
#thumbprints = new Map<string, ArrayBuffer>();

/**
* Gets a public key of the certificate
*/
Expand Down Expand Up @@ -439,7 +444,14 @@ export class X509Certificate extends PemData<Certificate> implements IPublicKeyC
}
crypto ??= cryptoProvider.get();

return await crypto.subtle.digest(algorithm, this.rawData);
const algorithmName = (typeof algorithm === "string" ? algorithm : algorithm.name).toUpperCase();
let thumbprint = this.#thumbprints.get(algorithmName);
if (!thumbprint) {
thumbprint = await crypto.subtle.digest(algorithm, this.rawData);
this.#thumbprints.set(algorithmName, thumbprint);
}

return thumbprint;
}

public async isSelfSigned(crypto = cryptoProvider.get()): Promise<boolean> {
Expand Down
42 changes: 35 additions & 7 deletions src/x509_chain_builder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { AsnConvert } from "@peculiar/asn1-schema";
import * as asn1X509 from "@peculiar/asn1-x509";
import { isEqual } from "pvtsutils";
import { AuthorityKeyIdentifierExtension, SubjectKeyIdentifierExtension } from "./extensions";
import { Convert, isEqual } from "pvtsutils";
import {
AuthorityKeyIdentifierExtension,
BasicConstraintsExtension,
KeyUsageFlags,
KeyUsagesExtension,
SubjectKeyIdentifierExtension,
} from "./extensions";
import { cryptoProvider } from "./provider";
import { X509Certificate } from "./x509_cert";
import { X509Certificates } from "./x509_certs";
Expand Down Expand Up @@ -38,19 +44,20 @@ export class X509ChainBuilder {

public async build(cert: X509Certificate, crypto = cryptoProvider.get()) {
const chain = new X509Certificates(cert);
const thumbprints = new Set<string>();
thumbprints.add(Convert.ToHex(await cert.getThumbprint(crypto)));

let current: X509Certificate | null = cert;
// eslint-disable-next-line no-cond-assign
while (current = await this.findIssuer(current, crypto)) {
// check out circular dependency
const thumbprint = await current.getThumbprint(crypto);
for (const item of chain) {
const thumbprint2 = await item.getThumbprint(crypto);
if (isEqual(thumbprint, thumbprint2)) {
throw new Error("Cannot build a certificate chain. Circular dependency.");
}
const thumbprintHex = Convert.ToHex(thumbprint);
if (thumbprints.has(thumbprintHex)) {
throw new Error("Cannot build a certificate chain. Circular dependency.");
}

thumbprints.add(thumbprintHex);
chain.push(current);
}

Expand Down Expand Up @@ -88,6 +95,27 @@ export class X509ChainBuilder {
}
}
}

// Check Basic Constraints
const basicConstraints = item.getExtension<BasicConstraintsExtension>(
asn1X509.id_ce_basicConstraints,
);
const isV3 = item.asn.tbsCertificate.version === 2;
if (isV3 && (!basicConstraints || !basicConstraints.ca)) {
// RFC 5280 4.2.1.9: The basic constraints extension MUST appear as a critical extension
// in all version 3 CA certificates.
continue;
}
if (basicConstraints && !basicConstraints.ca) {
continue;
}

// Check Key Usage
const keyUsage = item.getExtension<KeyUsagesExtension>(asn1X509.id_ce_keyUsage);
if (keyUsage && !(keyUsage.usages & KeyUsageFlags.keyCertSign)) {
continue;
}

try {
const algorithm = {
...item.publicKey.algorithm, ...cert.signatureAlgorithm,
Expand Down
107 changes: 107 additions & 0 deletions test/chain_validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Crypto } from "@peculiar/webcrypto";
import {
describe, it, expect,
} from "vitest";
import {
X509CertificateGenerator,
X509ChainBuilder,
BasicConstraintsExtension,
KeyUsagesExtension,
KeyUsageFlags,
cryptoProvider,
} from "../src";

// Set crypto provider if not already set (though the library might do it)
if (!cryptoProvider.get()) {
cryptoProvider.set(new Crypto());
}

describe("X509ChainBuilder Security", () => {
it("should NOT build a chain using a non-CA certificate as an issuer", async () => {
const crypto = cryptoProvider.get();

console.log("Generating Root CA...");
const rootAlg = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048,
};
const rootKeys = await crypto.subtle.generateKey(rootAlg, true, ["sign", "verify"]);
const rootCert = await X509CertificateGenerator.createSelfSigned({
serialNumber: "01",
name: "CN=Root CA",
notBefore: new Date("2020/01/01"),
notAfter: new Date("2030/01/01"),
signingAlgorithm: rootAlg,
keys: rootKeys,
extensions: [
new BasicConstraintsExtension(true, undefined, true), // CA=true
new KeyUsagesExtension(KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign, true),
],
});

console.log("Generating End Entity (EE) certificate (NOT a CA)...");
const eeAlg = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048,
};
const eeKeys = await crypto.subtle.generateKey(eeAlg, true, ["sign", "verify"]);
const eeCert = await X509CertificateGenerator.create({
serialNumber: "02",
subject: "CN=End Entity",
issuer: "CN=Root CA",
notBefore: new Date("2020/01/01"),
notAfter: new Date("2030/01/01"),
signingAlgorithm: rootAlg,
signingKey: rootKeys.privateKey,
publicKey: eeKeys.publicKey,
extensions: [
new BasicConstraintsExtension(false, undefined, true), // CA=false
new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true), // No keyCertSign
],
});

console.log("Generating Fake Certificate signed by EE (which should NOT be allowed)...");
const fakeAlg = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048,
};
const fakeKeys = await crypto.subtle.generateKey(fakeAlg, true, ["sign", "verify"]);
const fakeCert = await X509CertificateGenerator.create({
serialNumber: "03",
subject: "CN=Fake Cert",
issuer: "CN=End Entity",
notBefore: new Date("2020/01/01"),
notAfter: new Date("2030/01/01"),
signingAlgorithm: eeAlg,
signingKey: eeKeys.privateKey,
publicKey: fakeKeys.publicKey,
extensions: [
new BasicConstraintsExtension(false, undefined, true),
],
});

console.log("Building chain for Fake Cert...");
const chainBuilder = new X509ChainBuilder({ certificates: [rootCert, eeCert] });

const chain = await chainBuilder.build(fakeCert);
console.log("Chain built successfully (length):", chain.length);
for (const cert of chain) {
console.log(" - " + cert.subject);
}

// We expect the chain building to FAIL or at least NOT include the EE cert as a CA.
// However, since X509ChainBuilder might just follow signatures, it likely builds it.
// If it builds a chain of length 3 (Fake -> EE -> Root), it confirms the vulnerability
// in the context of "validating certificate chains".

// If the chain is built, we assert that we consider this a failure of the "Chain Builder"
// to enforce basic constraints.
expect(chain.length).not.toBe(3);
});
});
8 changes: 8 additions & 0 deletions test/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,14 @@ D314IEOg4mnS8Q==
extensions.push(akiExt);
}

if (name.toLowerCase().includes("ca")) {
extensions.push(new x509.BasicConstraintsExtension(true, undefined, true));
extensions.push(new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
true,
));
}

const res = await x509.X509CertificateGenerator.create({
serialNumber: "01",
subject: `CN=${name}`,
Expand Down
14 changes: 14 additions & 0 deletions test/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ describe("issues", () => {
name: "ECDSA",
hash: "SHA-256",
},
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
true,
),
],
}, crypto);

const intermediateKeys = await crypto.subtle.generateKey({
Expand All @@ -41,6 +48,13 @@ describe("issues", () => {
name: "ECDSA",
hash: "SHA-256",
},
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
true,
),
],
}, crypto);

const leafKeys = await crypto.subtle.generateKey({
Expand Down
41 changes: 41 additions & 0 deletions test/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
describe, it, expect,
} from "vitest";
import { Convert } from "pvtsutils";
import { generateCertificateSerialNumber } from "../src/utils";

describe("generateCertificateSerialNumber", () => {
it("should prepend 0x00 when MSB is set", () => {
const input = "80010203";
const serialNumber = generateCertificateSerialNumber(input);
const hex = Convert.ToHex(serialNumber);
expect(hex).toBe("0080010203");
});

it("should not prepend 0x00 when MSB is not set", () => {
const input = "7f010203";
const serialNumber = generateCertificateSerialNumber(input);
const hex = Convert.ToHex(serialNumber);
expect(hex).toBe("7f010203");
});

it("should remove leading zeros", () => {
const input = "00010203";
const serialNumber = generateCertificateSerialNumber(input);
const hex = Convert.ToHex(serialNumber);
expect(hex).toBe("010203");
});

it("should handle leading zeros followed by MSB set byte", () => {
// 00 removed -> 80... -> MSB set -> prepend 00
const input = "00800102";
const serialNumber = generateCertificateSerialNumber(input);
const hex = Convert.ToHex(serialNumber);
expect(hex).toBe("00800102");
});

it("should generate random serial number if input is empty", () => {
const serialNumber = generateCertificateSerialNumber(undefined);
expect(serialNumber.byteLength).toBeGreaterThan(0);
});
});