mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-06 06:25:28 +02:00
- 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.
375 lines
10 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
};
|