diff --git a/public/app.js b/public/app.js index 3a4e769..7d63562 100644 --- a/public/app.js +++ b/public/app.js @@ -199,3 +199,167 @@ dnsForm.addEventListener("submit", async (event) => { dnsButton.textContent = "Look Up DNS"; } }); +// --------------------------------------------------------- +// SSL Certificate Checker +// --------------------------------------------------------- + +// Get references to the SSL form and result elements. +const sslForm = document.getElementById("ssl-form"); +const sslHostnameInput = document.getElementById( + "ssl-hostname-input" +); +const sslButton = document.getElementById("ssl-button"); +const sslMessage = document.getElementById("ssl-message"); +const sslResultPanel = document.getElementById( + "ssl-result-panel" +); + +const sslStatusBadge = document.getElementById( + "ssl-status-badge" +); +const sslHostname = document.getElementById("ssl-hostname"); +const sslDaysRemaining = document.getElementById( + "ssl-days-remaining" +); +const sslValidFrom = document.getElementById("ssl-valid-from"); +const sslValidTo = document.getElementById("ssl-valid-to"); +const sslProtocol = document.getElementById("ssl-protocol"); +const sslCipher = document.getElementById("ssl-cipher"); +const sslAddress = document.getElementById("ssl-address"); +const sslCheckedAt = document.getElementById("ssl-checked-at"); + +const sslSubject = document.getElementById("ssl-subject"); +const sslIssuer = document.getElementById("ssl-issuer"); +const sslFingerprint = document.getElementById( + "ssl-fingerprint" +); + +const sslAuthorizationErrorBlock = document.getElementById( + "ssl-authorization-error-block" +); +const sslAuthorizationError = document.getElementById( + "ssl-authorization-error" +); + +// Convert certificate date strings into a readable local date. +function formatCertificateDate(value) { + if (!value) { + return "Unknown"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); +} + +// Choose readable text for the certificate expiration state. +function getExpirationText(daysRemaining) { + if (daysRemaining === null || daysRemaining === undefined) { + return "Unknown"; + } + + if (daysRemaining < 0) { + return `${Math.abs(daysRemaining)} days expired`; + } + + if (daysRemaining === 0) { + return "Expires today"; + } + + return `${daysRemaining} days`; +} + +// Handle SSL certificate form submissions. +sslForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const hostname = sslHostnameInput.value.trim(); + + // Reset the SSL interface before beginning the request. + sslMessage.textContent = "Checking certificate..."; + sslMessage.className = "status-message"; + sslResultPanel.hidden = true; + sslButton.disabled = true; + sslButton.textContent = "Checking..."; + + try { + const response = await fetch("/api/ssl", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ hostname }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error( + result.error || "The SSL certificate check failed." + ); + } + + // Show whether Node trusted the certificate chain and hostname. + if (result.authorized) { + sslStatusBadge.textContent = "Valid"; + sslStatusBadge.className = "status-badge online"; + } else { + sslStatusBadge.textContent = "Not trusted"; + sslStatusBadge.className = "status-badge offline"; + } + + // Populate the certificate summary. + sslHostname.textContent = result.hostname; + sslDaysRemaining.textContent = getExpirationText( + result.daysRemaining + ); + + sslValidFrom.textContent = formatCertificateDate( + result.validFrom + ); + sslValidTo.textContent = formatCertificateDate( + result.validTo + ); + + sslProtocol.textContent = result.protocol || "Unknown"; + sslCipher.textContent = result.cipher || "Unknown"; + + sslAddress.textContent = result.resolvedAddress + ? `${result.resolvedAddress} (IPv${result.addressFamily})` + : "Unknown"; + + sslCheckedAt.textContent = formatCertificateDate( + result.checkedAt + ); + + // Populate the longer certificate values. + sslSubject.textContent = result.subject || "Unknown"; + sslIssuer.textContent = result.issuer || "Unknown"; + sslFingerprint.textContent = + result.fingerprint256 || "Unavailable"; + + // Show the authorization problem only when one exists. + if (result.authorizationError) { + sslAuthorizationError.textContent = + result.authorizationError; + sslAuthorizationErrorBlock.hidden = false; + } else { + sslAuthorizationError.textContent = ""; + sslAuthorizationErrorBlock.hidden = true; + } + + sslMessage.textContent = + "Certificate check completed."; + sslResultPanel.hidden = false; + } catch (error) { + sslMessage.textContent = error.message; + sslMessage.className = "status-message error"; + } finally { + sslButton.disabled = false; + sslButton.textContent = "Check Certificate"; + } +}); \ No newline at end of file diff --git a/public/index.html b/public/index.html index 8f68fa3..d07f079 100644 --- a/public/index.html +++ b/public/index.html @@ -17,7 +17,8 @@

BYTEGEIST TOOLBOX

Practical Website and Network Tools

- Check a website's current status or inspect its public DNS records. + Check a website's current status, inspect its public DNS records, + or review its SSL certificate.

@@ -39,6 +40,15 @@ > DNS Lookup + + @@ -153,9 +163,123 @@

+ + + - + \ No newline at end of file diff --git a/public/styles.css b/public/styles.css index e5e42db..119bfe7 100644 --- a/public/styles.css +++ b/public/styles.css @@ -342,3 +342,49 @@ button:disabled { width: 100%; } } + +.certificate-detail-block { + display: grid; + gap: 6px; + margin-top: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: #0d1523; +} + +.certificate-detail-block span { + color: var(--muted); + font-size: 0.82rem; +} + +.certificate-detail-block p { + margin: 0; + overflow-wrap: anywhere; +} + +.certificate-detail-block code { + color: var(--accent); + font-family: + "Cascadia Code", + "Courier New", + monospace; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: normal; +} + +.certificate-error { + border-color: rgba(255, 107, 115, 0.5); +} + +@media (max-width: 640px) { + .tool-tabs { + grid-template-columns: 1fr; + } +} + +/* Always hide elements that use the HTML hidden attribute. */ +[hidden] { + display: none !important; +} \ No newline at end of file diff --git a/src/routes/ssl.js b/src/routes/ssl.js new file mode 100644 index 0000000..02fb06f --- /dev/null +++ b/src/routes/ssl.js @@ -0,0 +1,40 @@ +// Import Express so we can create the SSL route. +const express = require("express"); + +// Import hostname validation. +const { + validateHostname, +} = require("../middleware/validateHostname"); + +// Import the SSL certificate service. +const { + checkSslCertificate, +} = require("../services/sslService"); + +// Reuse the existing request rate limiter. +const { + uptimeRateLimiter, +} = require("../middleware/rateLimit"); + +const router = express.Router(); + +// POST /api/ssl +router.post("/", uptimeRateLimiter, async (req, res) => { + try { + // Validate and normalize the hostname or pasted URL. + const hostname = validateHostname(req.body.hostname); + + // Inspect the public TLS certificate. + const result = await checkSslCertificate(hostname); + + return res.json(result); + } catch (error) { + return res.status(400).json({ + error: + error.message || + "The SSL certificate could not be checked.", + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/server.js b/src/server.js index 75a9030..8e813b5 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,9 @@ const app = express(); // Otherwise, use port 3001 for local development. const PORT = process.env.PORT || 3001; +// Import the SSL certificate API route. +const sslRouter = require("./routes/ssl"); + // Allow the server to read JSON request bodies. app.use(express.json()); @@ -29,6 +32,9 @@ app.use("/api/uptime", uptimeRouter); // Handle DNS lookup requests. app.use("/api/dns", dnsRouter); +// Handle SSL certificate checks. +app.use("/api/ssl", sslRouter); + // Simple health-check route. app.get("/health", (req, res) => { res.json({ diff --git a/src/services/sslService.js b/src/services/sslService.js new file mode 100644 index 0000000..ae57f02 --- /dev/null +++ b/src/services/sslService.js @@ -0,0 +1,177 @@ +// Import Node's built-in TLS module for secure connections. +const tls = require("tls"); + +// Import DNS promises so we can resolve the hostname ourselves. +const dns = require("dns").promises; + +// Reuse the IP security check from the website status checker. +const { isPrivateIp } = require("./urlSecurityService"); + +// Maximum amount of time allowed for the TLS connection. +const TLS_TIMEOUT_MS = 5000; + +// Convert certificate subject or issuer objects into readable text. +function formatCertificateName(name) { + // Some servers may not provide all certificate fields. + if (!name || typeof name !== "object") { + return "Unknown"; + } + + // Keep the most useful certificate identity fields. + const fields = [ + ["CN", name.CN], + ["O", name.O], + ["OU", name.OU], + ["C", name.C], + ["ST", name.ST], + ["L", name.L], + ]; + + return fields + .filter(([, value]) => value) + .map(([label, value]) => `${label}=${value}`) + .join(", "); +} + +// Resolve a hostname and choose a public address. +async function resolvePublicAddress(hostname) { + let addresses; + + try { + // Resolve every IPv4 and IPv6 address for the hostname. + addresses = await dns.lookup(hostname, { + all: true, + verbatim: true, + }); + } catch { + throw new Error("The hostname could not be resolved."); + } + + // Keep only addresses that are safe to contact. + const publicAddresses = addresses.filter( + (result) => !isPrivateIp(result.address) + ); + + if (publicAddresses.length === 0) { + throw new Error( + "The hostname does not resolve to a public IP address." + ); + } + + // Use the first safe resolved address. + return publicAddresses[0]; +} + +// Calculate the number of whole days until certificate expiration. +function calculateDaysRemaining(validTo) { + const expirationTime = new Date(validTo).getTime(); + + if (Number.isNaN(expirationTime)) { + return null; + } + + const differenceMs = expirationTime - Date.now(); + + return Math.ceil(differenceMs / (1000 * 60 * 60 * 24)); +} + +// Inspect the TLS certificate for a public hostname. +async function checkSslCertificate(hostname) { + const resolvedAddress = await resolvePublicAddress(hostname); + + return new Promise((resolve, reject) => { + let finished = false; + + // Finish once and always close the socket. + function finish(callback, value, socket) { + if (finished) { + return; + } + + finished = true; + socket.destroy(); + callback(value); + } + + // Connect directly to the resolved public IP. + // servername preserves SNI and hostname certificate checking. + const socket = tls.connect({ + host: resolvedAddress.address, + port: 443, + servername: hostname, + + // Keep the connection available even when the certificate is invalid. + // We report the authorization error instead of hiding the certificate. + rejectUnauthorized: false, + }); + + socket.setTimeout(TLS_TIMEOUT_MS); + + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(true); + + if (!certificate || Object.keys(certificate).length === 0) { + finish( + reject, + new Error("The server did not provide a certificate."), + socket + ); + + return; + } + + const cipher = socket.getCipher(); + const daysRemaining = calculateDaysRemaining(certificate.valid_to); + + const result = { + hostname, + resolvedAddress: resolvedAddress.address, + addressFamily: resolvedAddress.family, + + authorized: socket.authorized, + authorizationError: socket.authorizationError || null, + + subject: formatCertificateName(certificate.subject), + issuer: formatCertificateName(certificate.issuer), + + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + daysRemaining, + + serialNumber: certificate.serialNumber || null, + fingerprint256: certificate.fingerprint256 || null, + + protocol: socket.getProtocol() || null, + cipher: cipher ? cipher.name : null, + + checkedAt: new Date().toISOString(), + }; + + finish(resolve, result, socket); + }); + + socket.once("timeout", () => { + finish( + reject, + new Error("The TLS connection timed out."), + socket + ); + }); + + socket.once("error", (error) => { + const safeMessage = + error.code === "ECONNREFUSED" + ? "The server refused the TLS connection." + : "The TLS connection could not be completed."; + + finish(reject, new Error(safeMessage), socket); + }); + }); +} + +module.exports = { + checkSslCertificate, + resolvePublicAddress, + calculateDaysRemaining, + formatCertificateName, +}; \ No newline at end of file diff --git a/tests/sslService.test.js b/tests/sslService.test.js new file mode 100644 index 0000000..4b8ec8f --- /dev/null +++ b/tests/sslService.test.js @@ -0,0 +1,141 @@ +// Import Node's built-in test tools. +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Import the SSL service functions we want to test. +const { + checkSslCertificate, + resolvePublicAddress, + calculateDaysRemaining, + formatCertificateName, +} = require("../src/services/sslService"); + + +// --------------------------------------------------------- +// formatCertificateName() tests +// --------------------------------------------------------- + +test("formats a complete certificate name", () => { + const result = formatCertificateName({ + CN: "example.com", + O: "Example Organization", + OU: "Technology", + C: "US", + ST: "Michigan", + L: "Kalamazoo", + }); + + assert.equal( + result, + "CN=example.com, O=Example Organization, OU=Technology, C=US, ST=Michigan, L=Kalamazoo" + ); +}); + +test("formats only certificate fields that exist", () => { + const result = formatCertificateName({ + CN: "example.com", + O: "Example Organization", + }); + + assert.equal( + result, + "CN=example.com, O=Example Organization" + ); +}); + +test("returns Unknown for missing certificate name data", () => { + assert.equal(formatCertificateName(null), "Unknown"); + assert.equal(formatCertificateName(undefined), "Unknown"); +}); + + +// --------------------------------------------------------- +// calculateDaysRemaining() tests +// --------------------------------------------------------- + +test("returns a positive number for a future expiration date", () => { + const futureDate = new Date( + Date.now() + 10 * 24 * 60 * 60 * 1000 + ).toISOString(); + + const result = calculateDaysRemaining(futureDate); + + assert.equal(result >= 9 && result <= 10, true); +}); + +test("returns a negative number for an expired certificate date", () => { + const pastDate = new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000 + ).toISOString(); + + const result = calculateDaysRemaining(pastDate); + + assert.equal(result < 0, true); +}); + +test("returns null for an invalid certificate date", () => { + assert.equal( + calculateDaysRemaining("not-a-real-date"), + null + ); +}); + + +// --------------------------------------------------------- +// resolvePublicAddress() tests +// --------------------------------------------------------- + +test("resolves a public hostname to a public IP address", async () => { + const result = await resolvePublicAddress("example.com"); + + assert.equal(typeof result.address, "string"); + assert.equal([4, 6].includes(result.family), true); +}); + +test("rejects a hostname that cannot be resolved", async () => { + await assert.rejects( + resolvePublicAddress( + "this-domain-should-not-exist-bytegeist-12345.com" + ), + /hostname could not be resolved/i + ); +}); + + +// --------------------------------------------------------- +// checkSslCertificate() integration test +// --------------------------------------------------------- + +test("checks a valid public SSL certificate", async () => { + const result = await checkSslCertificate("example.com"); + + assert.equal(result.hostname, "example.com"); + assert.equal(typeof result.resolvedAddress, "string"); + assert.equal([4, 6].includes(result.addressFamily), true); + + assert.equal(typeof result.authorized, "boolean"); + assert.equal(typeof result.subject, "string"); + assert.equal(typeof result.issuer, "string"); + + assert.equal(typeof result.validFrom, "string"); + assert.equal(typeof result.validTo, "string"); + assert.equal(typeof result.checkedAt, "string"); + + assert.equal( + result.daysRemaining === null || + typeof result.daysRemaining === "number", + true + ); + + assert.equal( + result.protocol === null || + typeof result.protocol === "string", + true + ); + + assert.equal( + result.cipher === null || + typeof result.cipher === "string", + true + ); +}); \ No newline at end of file