/** * Checks the certificate actually being served against what iOS requires. * * docker compose exec visitor-signin node scripts/tls-check.mjs * docker compose exec visitor-signin node scripts/tls-check.mjs --url https://10.0.0.5:8443 * * Pass the address the iPad uses. Most "not secure" reports are not a trust * problem at all: the name being typed is simply not in the certificate. */ import tls from 'node:tls'; import { execFileSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import config from '../src/config.js'; import { caCertificate, describe } from '../src/tls.js'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; } const url = new URL(arg('url', `https://127.0.0.1:${config.port}`)); const host = url.hostname; const port = Number(url.port || 443); const pass = (m) => console.log(` PASS ${m}`); const fail = (m) => console.log(` FAIL ${m}`); const warn = (m) => console.log(` WARN ${m}`); console.log(`\n Connecting to ${host}:${port}\n`); const socket = tls.connect( { host, port, servername: /^\d+\.\d+\.\d+\.\d+$/.test(host) ? undefined : host, rejectUnauthorized: false, timeout: 8000 }, () => { const peer = socket.getPeerX509Certificate?.() || null; if (!peer) { fail('The server did not present a certificate.'); socket.end(); process.exit(1); } // 1. Does the certificate cover the address being used? const sans = (peer.subjectAltName || '') .split(',') .map((s) => s.trim().replace(/^IP Address:/, 'IP:')); const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(host); const covered = sans.includes(isIp ? `IP:${host}` : `DNS:${host}`); if (covered) pass(`the certificate covers ${host}`); else { fail(`the certificate does NOT cover ${host}`); console.log(` it covers: ${sans.join(', ')}`); console.log(` add ${host} to HTTPS_HOSTNAMES, then: docker compose up -d`); } if (isIp) { warn('this is a bare IP address. Safari accepts IP certificates but Chrome on'); console.log(' Android does not, and a DHCP change silently breaks every kiosk.'); } // 2. Validity window. iOS refuses anything over 398 days. const span = (new Date(peer.validTo) - new Date(peer.validFrom)) / 86400000; if (span <= 397) pass(`valid for ${span.toFixed(1)} days (iOS allows up to 398)`); else if (span <= 398) warn(`valid for ${span.toFixed(2)} days — on Apple's limit, no margin`); else fail(`valid for ${span.toFixed(1)} days — iOS rejects anything over 398`); const daysLeft = (new Date(peer.validTo) - Date.now()) / 86400000; if (daysLeft > 0) pass(`expires in ${Math.floor(daysLeft)} days`); else fail('the certificate has expired'); // 3. The extensions iOS insists on. Node's X509Certificate does not expose // extended key usage, and toString() gives PEM rather than a readable dump, // so decode it with openssl. let text = ''; try { text = execFileSync('openssl', ['x509', '-noout', '-text'], { input: peer.toString(), encoding: 'utf8', }); } catch { text = ''; } if (peer.subjectAltName) pass('has a subject alternative name (iOS ignores the common name)'); else fail('no subject alternative name — iOS will reject it outright'); if (!text) warn('could not decode the certificate to check extended key usage'); else if (/TLS Web Server Authentication/.test(text)) pass('extended key usage includes serverAuth'); else fail('extended key usage does not include serverAuth — required since iOS 13'); const sigAlg = (text.match(/Signature Algorithm: (\S+)/) || [])[1]; if (!sigAlg) warn('could not read the signature algorithm'); else if (/sha1|md5/i.test(sigAlg)) fail(`signed with ${sigAlg} — iOS requires SHA-256 or better`); else pass(`signed with ${sigAlg}`); const key = peer.publicKey?.asymmetricKeyDetails; if (!key?.modulusLength || key.modulusLength >= 2048) { pass(`key size ${key?.modulusLength || 'n/a'} bits`); } else { fail(`key size ${key.modulusLength} bits — iOS requires at least 2048`); } // 4. Is the served certificate the one signed by our own authority? const ca = caCertificate(); if (ca) { const caCert = new crypto.X509Certificate(ca); if (peer.checkIssued(caCert)) pass('issued by this server\'s certificate authority'); else fail('NOT issued by this server\'s authority — the device is trusting the wrong thing'); console.log(''); console.log(' The authority the iPad must trust:'); console.log(` subject ${caCert.subject.replace(/\n/g, ', ')}`); console.log(` fingerprint ${caCert.fingerprint256}`); console.log(' Compare that with the certificate shown on the device under'); console.log(' Settings > General > VPN & Device Management.'); } // 5. A frequent mistake: uploading the server certificate to the MDM. console.log(''); console.log(' Served certificate (this is NOT what goes into Jamf):'); console.log(` subject ${peer.subject.replace(/\n/g, ', ')}`); console.log(` fingerprint ${peer.fingerprint256}`); console.log(''); console.log(' Jamf must be given the AUTHORITY certificate, the one whose name ends'); console.log(' "Local CA". Uploading the served certificate above instead is the most'); console.log(' common reason a profile installs cleanly and Safari still objects.'); console.log(''); socket.end(); } ); socket.on('timeout', () => { fail('timed out connecting'); socket.destroy(); process.exit(1); }); socket.on('error', (err) => { fail(`could not connect: ${err.message}`); process.exit(1); });