diff --git a/.claude-context/00-overview.md b/.claude-context/00-overview.md index c08d924..5841486 100644 --- a/.claude-context/00-overview.md +++ b/.claude-context/00-overview.md @@ -25,16 +25,10 @@ console. A Google Sheet mirrors who is currently on site for evacuation roll cal - Check what's deployed: `docker compose exec visitor-signin npm run version`. - Printing diagnostics: `docker compose exec visitor-signin node scripts/print-test.mjs`. -## Branches -- `deploy` — live branch (this one). -- `feature/native-ql-protocol` — unmerged native Node QL printing stack; see the 2026-09-07 session before merging. -- `main` — stale since 2026-09-07. - ## Where to look - `01-architecture.md` — current design and the reasoning behind it. - `sessions/` — dated session records. - `README.md` in the repo root — operator documentation. ## Session Log -- 2026-09-07 — Native Brother QL Protocol (replacing brother_ql subprocess) — Built native raster/TCP print modules and photo dithering on a feature branch; tested, not yet merged into deploy. - 2026-09-23 — Visitor sign-in kiosk: build, HTTPS, and server-side label printing — Built the app end to end; added multi-site, 2FA admin accounts, self-signed CA for Jamf-managed iPads, server-side QL-820NWB printing (found `--red` is mandatory on DK-22251), single-tab evacuation sheet, branding, and photo halftoning. diff --git a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md index 20f73e1..3b4393e 100644 --- a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md +++ b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md @@ -200,3 +200,25 @@ leave a stale lockout. **Files:** `src/routes/admin.js` (PATCH handler), `public/js/admin.js` (edit modal shows the current PIN, relabelled field, shows the card after a change), `public/css/admin.css`, `README.md`. + +--- + +## Addendum — iPad trust troubleshooting + +Reported: Safari and Chrome on a Jamf-managed iPad still report an insecure connection after the +certificate was imported into Jamf. + +**Found:** the server certificate was being issued for *exactly* 398 days — Apple's ceiling, with +zero margin for rounding or clock skew. `SERVER_DAYS` in `src/tls.js` reduced 398 → 396. + +**Added:** `scripts/tls-check.mjs` (`npm run tls-check`), which connects to a given URL and checks +what iOS requires: address covered by a SAN, validity span, SAN present, `serverAuth` EKU, +signature algorithm, key size, and that the served leaf was issued by this server's CA. It prints +the authority and leaf fingerprints side by side. + +Note: Node's `X509Certificate` does not expose extended key usage and `toString()` returns PEM, +so the EKU and signature checks shell out to `openssl x509 -noout -text`. + +**Most likely causes, still unconfirmed on the device:** the *served* certificate was uploaded to +Jamf instead of the *authority*; or the address the iPad uses is not in `HTTPS_HOSTNAMES` (the +host `.env` was previously missing that line entirely). diff --git a/package.json b/package.json index 3af8cd7..a106917 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", "print-test": "node scripts/print-test.mjs", "printer-status": "node scripts/printer-status.mjs", - "print-probe": "node scripts/print-probe.mjs" + "print-probe": "node scripts/print-probe.mjs", + "tls-check": "node scripts/tls-check.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/tls-check.mjs b/scripts/tls-check.mjs new file mode 100644 index 0000000..3b59380 --- /dev/null +++ b/scripts/tls-check.mjs @@ -0,0 +1,140 @@ +/** + * 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); +}); diff --git a/src/tls.js b/src/tls.js index e8201f3..5b94405 100644 --- a/src/tls.js +++ b/src/tls.js @@ -17,7 +17,9 @@ import { createZip } from './zip.js'; */ const CA_DAYS = 3650; -const SERVER_DAYS = 398; +// Apple rejects a TLS server certificate whose validity exceeds 398 days. Exactly +// 398 sits on the line with no room for rounding or clock skew, so leave a margin. +const SERVER_DAYS = 396; const RENEW_WITHIN_DAYS = 30; function certDir() {