Public Access
CertZipDL
This commit is contained in:
1 parent
b1a793302d
commit
922ce99d25
13 files changed
+577
-63
No files matched your search
@@ -1275,6 +1275,20 @@ router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** All encodings plus instructions, as one archive browsers will actually download. */
|
||||
router.get('/tls/ca-bundle.zip', (req, res) => {
|
||||
try {
|
||||
const zip = tls.caBundleZip();
|
||||
if (!zip) return res.status(404).send('No certificate authority has been generated yet.');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-certificates.zip"');
|
||||
res.setHeader('Content-Length', zip.length);
|
||||
res.send(zip);
|
||||
} catch (err) {
|
||||
res.status(500).send(`Could not build the bundle: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tls/renew', requireOwner, (req, res) => {
|
||||
try {
|
||||
// A brand new CA means every kiosk device has to trust it again, so it is
|
||||
|
||||
@@ -133,6 +133,26 @@ function startRedirectServer() {
|
||||
|
||||
http
|
||||
.createServer((req, res) => {
|
||||
// A zip, because browsers block bare certificate downloads.
|
||||
if (req.url === '/ca.zip') {
|
||||
try {
|
||||
const zip = tls.caBundleZip();
|
||||
if (!zip) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('No certificate authority has been generated yet.');
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': 'attachment; filename="visitor-signin-certificates.zip"',
|
||||
'Content-Length': zip.length,
|
||||
});
|
||||
return res.end(zip);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
return res.end(`Could not build the bundle: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// DER for Apple tooling, PEM for everything else.
|
||||
if (req.url === '/ca.cer' || req.url === '/ca.der') {
|
||||
try {
|
||||
|
||||
+75
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import config from './config.js';
|
||||
import { createZip } from './zip.js';
|
||||
|
||||
/**
|
||||
* Certificates for an internal-only kiosk.
|
||||
@@ -250,6 +251,80 @@ export function caCertificateDer() {
|
||||
return openssl(['x509', '-in', p.caCert, '-outform', 'der']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every form of the authority certificate in one archive, with instructions.
|
||||
*
|
||||
* Browsers increasingly refuse to download a bare .crt or .cer as a dangerous
|
||||
* file type, which leaves no way to get the certificate onto a device. A zip is
|
||||
* accepted, and carrying all the encodings means whichever tool is being fed —
|
||||
* Jamf, Windows, Android — has the one it wants.
|
||||
*/
|
||||
export function caBundleZip() {
|
||||
const p = paths();
|
||||
if (!fs.existsSync(p.caCert)) return null;
|
||||
|
||||
const pem = fs.readFileSync(p.caCert);
|
||||
const der = caCertificateDer();
|
||||
const info = describe();
|
||||
|
||||
const readme = [
|
||||
`${config.siteName} — certificate authority`,
|
||||
'='.repeat(60),
|
||||
'',
|
||||
'Install ONE of these on each device. They are the same certificate in',
|
||||
'different encodings; which one you need depends on the tool.',
|
||||
'',
|
||||
' visitor-signin-ca.cer binary DER. Jamf Pro, Apple Configurator, iOS, macOS.',
|
||||
' visitor-signin-ca.crt PEM text. Windows, Android, Chromebook, Linux.',
|
||||
' visitor-signin-ca.pem identical to the .crt, for tools expecting .pem.',
|
||||
'',
|
||||
'Fingerprint (SHA-256)',
|
||||
` ${info.ca?.fingerprint || 'unknown'}`,
|
||||
'',
|
||||
'Check this matches what the device shows before trusting it.',
|
||||
'',
|
||||
'Valid until',
|
||||
` ${info.ca?.validTo || 'unknown'}`,
|
||||
'',
|
||||
'The server certificate currently covers',
|
||||
` ${(info.server?.names || ['unknown']).join('\n ')}`,
|
||||
'',
|
||||
'Installing',
|
||||
'----------',
|
||||
'Jamf Pro Devices > Configuration Profiles > New > Certificate payload.',
|
||||
' Upload the .cer, scope to the kiosk devices, save. A root',
|
||||
' certificate delivered by MDM is trusted for TLS automatically.',
|
||||
'',
|
||||
'Windows Double-click the .crt > Install Certificate > Local Machine >',
|
||||
' Place all certificates in the following store > Trusted Root',
|
||||
' Certification Authorities.',
|
||||
'',
|
||||
'Android Settings > Security > Encryption & credentials > Install a',
|
||||
' certificate > CA certificate, then pick the .crt. Chrome on',
|
||||
' Android will not accept a certificate for a bare IP address,',
|
||||
' so reach the kiosk by hostname.',
|
||||
'',
|
||||
'Chromebook Settings > Privacy and security > Security > Manage',
|
||||
' certificates > Authorities > Import, then pick the .crt.',
|
||||
'',
|
||||
'iOS by hand Open the .crt in Safari, allow the profile, install it under',
|
||||
' Settings > General > VPN & Device Management, THEN turn it on',
|
||||
' under Settings > General > About > Certificate Trust Settings.',
|
||||
' Both steps are needed when installing by hand.',
|
||||
'',
|
||||
'Renewing the server certificate does not change this authority, so devices',
|
||||
'only need this done once.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
return createZip([
|
||||
{ name: 'visitor-signin-ca.cer', data: der },
|
||||
{ name: 'visitor-signin-ca.crt', data: pem },
|
||||
{ name: 'visitor-signin-ca.pem', data: pem },
|
||||
{ name: 'README.txt', data: readme },
|
||||
]);
|
||||
}
|
||||
|
||||
export function caCertificate() {
|
||||
const p = paths();
|
||||
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A small ZIP writer, so a bundle of certificates can be offered as a single
|
||||
* download. Browsers increasingly refuse .crt and .cer files as dangerous types,
|
||||
* and a zip is accepted where the bare certificate is not.
|
||||
*
|
||||
* Only what is needed here: a handful of small files, no directories, no
|
||||
* encryption, no zip64. Written directly rather than pulling in a dependency for
|
||||
* sixty lines of header packing.
|
||||
*/
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = -1;
|
||||
for (const byte of buffer) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff];
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
/** MS-DOS packs the date and time into two 16 bit words, with two second resolution. */
|
||||
function dosStamp(date) {
|
||||
const time =
|
||||
(date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
||||
const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
||||
return { time, day };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{name: string, data: Buffer|string}>} files
|
||||
* @returns {Buffer} the complete archive
|
||||
*/
|
||||
export function createZip(files) {
|
||||
const now = new Date();
|
||||
const { time, day } = dosStamp(now);
|
||||
const locals = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const name = Buffer.from(file.name, 'utf8');
|
||||
const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, 'utf8');
|
||||
const compressed = zlib.deflateRawSync(raw);
|
||||
// Storing uncompressed is allowed and is smaller for data that does not shrink.
|
||||
const useDeflate = compressed.length < raw.length;
|
||||
const data = useDeflate ? compressed : raw;
|
||||
const method = useDeflate ? 8 : 0;
|
||||
const crc = crc32(raw);
|
||||
|
||||
const localHeader = Buffer.alloc(30);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature
|
||||
localHeader.writeUInt16LE(20, 4); // version needed
|
||||
localHeader.writeUInt16LE(0, 6); // flags
|
||||
localHeader.writeUInt16LE(method, 8);
|
||||
localHeader.writeUInt16LE(time, 10);
|
||||
localHeader.writeUInt16LE(day, 12);
|
||||
localHeader.writeUInt32LE(crc, 14);
|
||||
localHeader.writeUInt32LE(data.length, 18);
|
||||
localHeader.writeUInt32LE(raw.length, 22);
|
||||
localHeader.writeUInt16LE(name.length, 26);
|
||||
localHeader.writeUInt16LE(0, 28); // extra field length
|
||||
|
||||
locals.push(localHeader, name, data);
|
||||
|
||||
const centralHeader = Buffer.alloc(46);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0); // central directory signature
|
||||
centralHeader.writeUInt16LE(20, 4); // version made by
|
||||
centralHeader.writeUInt16LE(20, 6); // version needed
|
||||
centralHeader.writeUInt16LE(0, 8);
|
||||
centralHeader.writeUInt16LE(method, 10);
|
||||
centralHeader.writeUInt16LE(time, 12);
|
||||
centralHeader.writeUInt16LE(day, 14);
|
||||
centralHeader.writeUInt32LE(crc, 16);
|
||||
centralHeader.writeUInt32LE(data.length, 20);
|
||||
centralHeader.writeUInt32LE(raw.length, 24);
|
||||
centralHeader.writeUInt16LE(name.length, 28);
|
||||
centralHeader.writeUInt16LE(0, 30); // extra
|
||||
centralHeader.writeUInt16LE(0, 32); // comment
|
||||
centralHeader.writeUInt16LE(0, 34); // disk number
|
||||
centralHeader.writeUInt16LE(0, 36); // internal attributes
|
||||
centralHeader.writeUInt32LE(0, 38); // external attributes
|
||||
centralHeader.writeUInt32LE(offset, 42); // offset of local header
|
||||
|
||||
central.push(centralHeader, name);
|
||||
offset += localHeader.length + name.length + data.length;
|
||||
}
|
||||
|
||||
const centralBuffer = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0); // end of central directory
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(files.length, 8);
|
||||
end.writeUInt16LE(files.length, 10);
|
||||
end.writeUInt32LE(centralBuffer.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20); // comment length
|
||||
|
||||
return Buffer.concat([...locals, centralBuffer, end]);
|
||||
}
|
||||
Reference in new issue
Block a user