Files
dokploy/apps/dokploy/components/dashboard/settings/certificates/utils.ts
Mauricio Siu 91d4fe2420 fix(certificates): improve error handling in certificate extraction functions
- Updated the `extractExpirationDate` and `extractCommonName` functions to return null instead of throwing errors when encountering unexpected structures in the certificate data. This change enhances the robustness of the certificate parsing logic.
2026-04-03 23:05:16 -06:00

375 lines
10 KiB
TypeScript

// @ts-nocheck
// Split certificate chain into individual certificates
export const splitCertificateChain = (certData: string): string[] => {
const certRegex =
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
const matches = certData.match(certRegex);
return matches || [];
};
export const extractExpirationDate = (certData: string): Date | null => {
try {
// Decode PEM base64 to DER binary
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binStr = atob(b64);
const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i);
}
let offset = 0;
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
len = (len << 8) + der[pos++];
}
}
return { length: len, offset: pos };
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) return null;
const validityLen = readLength(offset);
offset = validityLen.offset;
// notBefore
offset++;
const notBeforeLen = readLength(offset);
offset = notBeforeLen.offset + notBeforeLen.length;
// notAfter
offset++;
const notAfterLen = readLength(offset);
const notAfterStr = new TextDecoder().decode(
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
);
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
function parseTime(str: string): Date {
if (str.length === 13) {
// UTCTime YYMMDDhhmmssZ
const year = Number.parseInt(str.slice(0, 2), 10);
const fullYear = year < 50 ? 2000 + year : 1900 + year;
return new Date(
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
);
}
if (str.length === 15) {
// GeneralizedTime YYYYMMDDhhmmssZ
return new Date(
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
);
}
throw new Error("Invalid ASN.1 time format");
}
return parseTime(notAfterStr);
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
export const extractCommonName = (certData: string): string | null => {
try {
// Decode PEM base64 to DER binary
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binStr = atob(b64);
const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i);
}
let offset = 0;
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: <explanation>
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
// biome-ignore lint/style/noParameterAssign: <explanation>
len = (len << 8) + der[pos++];
}
}
return { length: len, offset: pos };
}
// Helper: skip a field
function skipField(pos: number): number {
// biome-ignore lint/style/noParameterAssign: <explanation>
pos++;
const fieldLen = readLength(pos);
return fieldLen.offset + fieldLen.length;
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber
offset = skipField(offset);
// Skip signature
offset = skipField(offset);
// Skip issuer
offset = skipField(offset);
// Skip validity
offset = skipField(offset);
// Subject sequence - where we find the CN
if (der[offset++] !== 0x30) return null;
const subjectLen = readLength(offset);
const subjectEnd = subjectLen.offset + subjectLen.length;
offset = subjectLen.offset;
// Parse subject RDNs looking for CN (OID 2.5.4.3)
while (offset < subjectEnd) {
if (der[offset++] !== 0x31) continue; // SET
const setLen = readLength(offset);
offset = setLen.offset;
if (der[offset++] !== 0x30) continue; // SEQUENCE
const seqLen = readLength(offset);
offset = seqLen.offset;
if (der[offset++] !== 0x06) continue; // OID
const oidLen = readLength(offset);
offset = oidLen.offset;
// Check if OID is 2.5.4.3 (commonName)
const oid = Array.from(der.slice(offset, offset + oidLen.length));
offset += oidLen.length;
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
if (
oid.length === 3 &&
oid[0] === 0x55 &&
oid[1] === 0x04 &&
oid[2] === 0x03
) {
// Next should be the string value
const strType = der[offset++];
const strLen = readLength(offset);
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
return new TextDecoder().decode(cnBytes);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate CN:", error);
return null;
}
};
// Extract the Common Name from the first (leaf) certificate in a chain
export const extractLeafCommonName = (certData: string): string | null => {
const certs = splitCertificateChain(certData);
if (certs.length === 0) return null;
return extractCommonName(certs[0]);
};
// Extract expiration dates from all certificates in a chain
export const extractAllExpirationDates = (
certData: string,
): Array<{
cert: string;
index: number;
expirationDate: Date | null;
commonName: string | null;
}> => {
const certs = splitCertificateChain(certData);
return certs.map((cert, index) => ({
cert,
index,
expirationDate: extractExpirationDate(cert),
commonName: extractCommonName(cert),
}));
};
// Get the earliest expiration date from a certificate chain
export const getEarliestExpirationDate = (certData: string): Date | null => {
const expirationDates = extractAllExpirationDates(certData);
const validDates = expirationDates
.filter((item) => item.expirationDate !== null)
.map((item) => item.expirationDate as Date);
if (validDates.length === 0) return null;
return new Date(Math.min(...validDates.map((date) => date.getTime())));
};
export const getExpirationStatus = (certData: string) => {
const chainInfo = getCertificateChainInfo(certData);
const expirationDate = chainInfo.isChain
? getEarliestExpirationDate(certData)
: extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
export const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};
// Get detailed expiration information for all certificates in a chain
export const getCertificateChainExpirationDetails = (certData: string) => {
const allExpirations = extractAllExpirationDates(certData);
const now = new Date();
return allExpirations.map(({ index, expirationDate, commonName }) => {
if (!expirationDate) {
return {
index,
label: `Certificate ${index + 1}`,
commonName,
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
expirationDate: null,
};
}
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
let status: "expired" | "warning" | "valid";
let className: string;
let message: string;
if (daysUntilExpiration < 0) {
status = "expired";
className = "text-red-500";
message = `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
} else if (daysUntilExpiration <= 30) {
status = "warning";
className = "text-yellow-500";
message = `Expires in ${daysUntilExpiration} days`;
} else {
status = "valid";
className = "text-muted-foreground";
message = `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`;
}
return {
index,
label:
index === 0
? `Certificate ${index + 1} (Leaf)`
: `Certificate ${index + 1}`,
commonName,
status,
className,
message,
expirationDate,
daysUntilExpiration,
};
});
};