Files
2026-09-30 12:48:16 +10:00

141 lines
5.8 KiB
JavaScript

/**
* 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);
});