Public Access
Cert Fix
This commit is contained in:
1 parent
06b4616356
commit
215c38849b
5 files changed
+167
-8
No files matched your search
@@ -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`.
|
- Check what's deployed: `docker compose exec visitor-signin npm run version`.
|
||||||
- Printing diagnostics: `docker compose exec visitor-signin node scripts/print-test.mjs`.
|
- 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
|
## Where to look
|
||||||
- `01-architecture.md` — current design and the reasoning behind it.
|
- `01-architecture.md` — current design and the reasoning behind it.
|
||||||
- `sessions/` — dated session records.
|
- `sessions/` — dated session records.
|
||||||
- `README.md` in the repo root — operator documentation.
|
- `README.md` in the repo root — operator documentation.
|
||||||
|
|
||||||
## Session Log
|
## 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.
|
- 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.
|
||||||
@@ -200,3 +200,25 @@ leave a stale lockout.
|
|||||||
|
|
||||||
**Files:** `src/routes/admin.js` (PATCH handler), `public/js/admin.js` (edit modal shows the
|
**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`.
|
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).
|
||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
||||||
"print-test": "node scripts/print-test.mjs",
|
"print-test": "node scripts/print-test.mjs",
|
||||||
"printer-status": "node scripts/printer-status.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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+3
-1
@@ -17,7 +17,9 @@ import { createZip } from './zip.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const CA_DAYS = 3650;
|
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;
|
const RENEW_WITHIN_DAYS = 30;
|
||||||
|
|
||||||
function certDir() {
|
function certDir() {
|
||||||
|
|||||||
Reference in new issue
Block a user