From 94549a98836dade2f2daf045d952f3fe1b7483b3 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Fri, 27 Feb 2026 08:30:02 +0000 Subject: [PATCH] Add build and sign scripts --- scripts/build.ts | 771 +++++++++++++++++++++++++++++++++++++++++++++++ scripts/new.ts | 382 +++++++++++++++++++++++ scripts/sign.ts | 108 +++++++ 3 files changed, 1261 insertions(+) create mode 100755 scripts/build.ts create mode 100755 scripts/new.ts create mode 100755 scripts/sign.ts diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100755 index 0000000..265cfd8 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,771 @@ +#!/usr/bin/env node +import { CMS } from 'micro-key-producer/x509.js'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type LangData = { + code: string; + name: string; + table_columns: { + name: string; + region: string; + censorship: string; + notes: string; + install_signed: string; + install_unsigned: string; + }; + yes: string; + no: string; +}; + +type Lang = { + code: string; + name: string; + mdFile: string; + jsonFile: string; + data: LangData; +}; + +type RuleParam = { DomainAction: string; Domains: string[] }; +type Rule = { + Action: string; + InterfaceTypeMatch?: string; + SSIDMatch?: string[]; + ActionParameters?: RuleParam[]; +}; +type DnsCfg = { + protocol: string; + server: string; + addresses: string[]; +}; +type DnsInput = { + protocol: string; + server: string; + addresses: string[]; +}; +type DnsValidateOpts = { requireTlsAddresses?: boolean }; + +type PayloadCfg = { + description?: string; + displayName: string; + identifier: string; + uuid: string; + type?: string; + version?: number; + organization?: string; + prohibitDisablement?: boolean; +}; + +type TopCfg = { + description: string; + displayName: string; + identifier: string; + removalDisallowed?: boolean; + scope?: string; + type?: string; + uuid: string; + version?: number; + organization?: string; + consentTextDefault?: string; +}; + +type CertCfg = { + fileName: string; + data: string; + displayName: string; + identifier: string; + uuid: string; + type?: string; + version?: number; +}; + +export type ProfileCfg = { + // Controls plist string escaping; kept for compatibility with old provider entries. + escapeXML?: boolean; + // Naming inputs used to derive PayloadDisplayName / top display fields when explicit fields are absent. + name?: string; + fullName?: string; + // Explicit top payload display name fallback when top.displayName is not set. + topName?: string; + // DNS endpoint (DoH URL or DoT hostname) and optional resolver IP hints for Apple DNSSettings payload. + ServerURLOrName?: string; + ServerAddresses?: string[]; + // Inner payload fields (com.apple.dnsSettings.managed) shown in UI and used for stable ids. + PayloadDisplayName?: string; + PayloadDescription?: string; + PayloadIdentifier?: string; + PayloadUUID?: string; + PayloadType?: string; + PayloadVersion?: number; + // Apple DNS payload flag: true prevents user from toggling DNS settings off in UI. + ProhibitDisablement?: boolean; + // Optional Apple consent text block; used by some providers for privacy-policy notice. + ConsentTextDefault?: string; + // Structured variants used by CLI/tests; normalize() supports both structured and flat forms. + dns?: DnsCfg; + payload?: PayloadCfg; + // Structured top-level configuration payload; if absent, built from defaults + topName. + top?: TopCfg; + // Optional on-demand match rules (template use-case). + onDemandRules?: Rule[]; + // Optional additional certificate payloads embedded into profile. + certificates?: CertCfg[]; + // Compact detached signature (hex). Generator rebuilds attached CMS signed/*.mobileconfig from this. + signature?: string; +}; + +type Provider = { + // Provider metadata for README table + generated links. + id: string; + profile: string; + // Optional naming defaults consumed by providerFile()/normalize(). + name?: string; + fullName?: string; + ServerAddresses?: string[]; + // Optional output filename override (template provider). + file?: string; + // Hidden providers are excluded from README provider table. + hidden?: boolean; + website?: string; + region?: string | string[]; + censorship?: boolean; + // Localized labels and notes used in README rendering. + names: Record; + notes: Record; + // Per-protocol profile definitions. + https?: ProfileCfg; + tls?: ProfileCfg; + formats?: { + unsigned: { https: boolean; tls: boolean }; + signed: { https: boolean; tls: boolean }; + }; + sourceFile?: string; +}; +type ProviderFileInfo = Pick; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +// Moved under scripts/: resolve repo-root data paths explicitly. +const ROOT_DIR = path.join(__dirname, '..'); +const CERTS_DIR = path.join(ROOT_DIR, 'certs'); +const CERT_PEM_FILE = path.join(CERTS_DIR, 'cert.pem'); +const CHAIN_PEM_FILE = path.join(CERTS_DIR, 'chain.pem'); +// Shared CMS algorithm parameters for repo signing/building. +// We intentionally omit signingTime and S/MIME capabilities for stable, minimal signed attributes. +export const SIGN_OPTS = { extraEntropy: false } as const; // Deterministic signatures +const LANGUAGES_DIR = path.join(ROOT_DIR, 'src-languages'); +const PROVIDERS_PATH = path.join(ROOT_DIR, 'src'); +const DEFAULT_LANG = 'en'; +const OUTPUT_DIR = ROOT_DIR; +const REPO_RAW = 'https://github.com/paulmillr/encrypted-dns/raw/master'; +const outPath = (p: string) => path.join(ROOT_DIR, p); + +const REGIONS: Record = { + US: 'πŸ‡ΊπŸ‡Έ', + CN: 'πŸ‡¨πŸ‡³', + RU: 'πŸ‡·πŸ‡Ί', + NL: 'πŸ‡³πŸ‡±', + DE: 'πŸ‡©πŸ‡ͺ', + SG: 'πŸ‡ΈπŸ‡¬', + CA: 'πŸ‡¨πŸ‡¦', + FR: 'πŸ‡«πŸ‡·', + CH: 'πŸ‡¨πŸ‡­', + SE: 'πŸ‡ΈπŸ‡ͺ', + CZ: 'πŸ‡¨πŸ‡Ώ', +}; + +const escapeXMLText = (s: string) => + s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +export const validId = (s: string) => /^[A-Za-z0-9.-]+$/.test(s); +export const validHost = (s: string) => + /^(?=.{1,253}$)(?!-)(?:[A-Za-z0-9-]{1,63}\.)*[A-Za-z0-9-]{1,63}$/.test(s) && !s.includes('..'); +export const splitCsv = (s: string) => + s + .split(',') + .map((x) => x.trim()) + .filter(Boolean); +const bad = (where: string, msg: string): never => { + throw new Error(`${where}: ${msg}`); +}; +const validateDnsInputFor = ( + x: DnsInput, + where: string, + protocol: 'https' | 'tls', + opts: DnsValidateOpts = {} +) => { + const requireTlsAddresses = + opts.requireTlsAddresses !== undefined ? opts.requireTlsAddresses : true; + if (!x.server.trim()) bad(where, 'server is required'); + if (protocol === 'https') { + let url: URL; + try { + url = new URL(x.server); + } catch { + bad(where, `https server must be a valid URL, got: ${x.server}`); + } + if (url.protocol !== 'https:') + bad(where, `https server URL must use https://, got: ${x.server}`); + } else if (!validHost(x.server)) bad(where, `tls server must be a hostname, got: ${x.server}`); + for (const ip of x.addresses) if (!net.isIP(ip)) bad(where, `invalid IP address: ${ip}`); + if (requireTlsAddresses && protocol === 'tls' && x.addresses.length === 0) + bad(where, 'tls requires at least one IP in --addresses'); +}; +export const validateDnsInput = (x: DnsInput, where: string, opts: DnsValidateOpts = {}) => { + const protocol = x.protocol.toLowerCase(); + if (protocol !== 'https' && protocol !== 'tls') + bad(where, `protocol: expected https|tls, got ${x.protocol}`); + validateDnsInputFor(x, where, protocol, opts); +}; +export const validateProfileInput = ( + x: ProfileCfg, + where: string, + expectedProtocol?: 'https' | 'tls' +) => { + const dns = x.dns || { + protocol: expectedProtocol || '', + server: x.ServerURLOrName || '', + addresses: x.ServerAddresses || [], + }; + if (expectedProtocol) + validateDnsInputFor(dns, where, expectedProtocol, { requireTlsAddresses: false }); + else validateDnsInput(dns, where); + const payloadId = x.payload?.identifier || x.PayloadIdentifier; + const topId = x.top?.identifier; + const scope = x.top?.scope; + if (payloadId && !validId(payloadId)) + bad(where, `payload identifier must match [A-Za-z0-9.-], got: ${payloadId}`); + if (topId && !validId(topId)) + bad(where, `top payload identifier must match [A-Za-z0-9.-], got: ${topId}`); + if (scope && scope !== 'System' && scope !== 'User') + bad(where, `scope: expected System|User, got ${scope}`); +}; + +const certData = (src: string) => + src + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s/g, ''); +const UUID_DNS_NS = new Uint8Array([ + 0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, +]); +const hex = (b: Uint8Array) => Buffer.from(b).toString('hex'); +const uuidFormat = (b: Uint8Array, upper: boolean) => { + const s = hex(b); + const out = `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`; + return upper ? out.toUpperCase() : out.toLowerCase(); +}; +const uuidV5 = (seed: string, upper: boolean) => { + const msg = Buffer.from(seed, 'utf8'); + const h = createHash('sha1').update(Buffer.from(UUID_DNS_NS)).update(msg).digest(); + const out = new Uint8Array(h.subarray(0, 16)); + out[6] = (out[6] & 0x0f) | 0x50; + out[8] = (out[8] & 0x3f) | 0x80; + return uuidFormat(out, upper); +}; +export const deterministicUuid = ( + rootIdentifier: string, + tag: 'root' | 'payload', + rel: string, + i?: number +) => { + if (tag === 'root') return uuidV5(`${rootIdentifier}::root::${rel}`, true); + return uuidV5(`${rootIdentifier}::payload::${i || 0}::${rel}`, true); +}; +const deterministicPayloadIdentifier = (rootIdentifier: string, rel: string, i = 0) => + `com.apple.dnsSettings.managed.${uuidV5(`${rootIdentifier}::payload::${i}::${rel}`, false)}`; + +export const providerFile = (p: ProviderFileInfo, https: boolean, signed?: boolean) => { + if (p.file) return `${signed ? 'signed' : 'profiles'}/${p.file}`; + const postfix = (_pr: ProviderFileInfo, isHttps: boolean) => (isHttps ? 'https' : 'tls'); + const name = p.name || p.id; + return `${signed ? 'signed' : 'profiles'}/${name}-${postfix(p, https)}.mobileconfig`; +}; + +const languages: Lang[] = fs + .readdirSync(LANGUAGES_DIR) + .filter((f: string) => f.endsWith('.json')) + .sort() + .map((f: string) => { + const p = path.join(LANGUAGES_DIR, f); + const data = JSON.parse(fs.readFileSync(p, 'utf8')) as LangData; + return { + code: data.code, + name: data.name, + mdFile: p.replace('.json', '.md'), + jsonFile: p, + data, + }; + }); + +const providers: Provider[] = fs + .readdirSync(PROVIDERS_PATH) + .sort() + .map((name: string) => { + const sourceFile = path.join(PROVIDERS_PATH, name); + const p = JSON.parse(fs.readFileSync(sourceFile, 'utf8')) as Provider; + const unsigned = { https: !!p.https, tls: !!p.tls }; + const signed = { + https: !!p.https?.signature || fs.existsSync(outPath(providerFile(p, true, true))), + tls: !!p.tls?.signature || fs.existsSync(outPath(providerFile(p, false, true))), + }; + return { ...p, sourceFile, formats: { unsigned, signed } }; + }); + +const generateSigned = () => { + for (const p of providers) { + if (!p.formats) continue; + p.formats.signed.https = fs.existsSync(outPath(providerFile(p, true, true))); + p.formats.signed.tls = fs.existsSync(outPath(providerFile(p, false, true))); + } +}; + +const FULLWIDTH_PATTERN = + /[\u1100-\u115F\u2329\u232A\u2E80-\u303E\u3040-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/u; +const chrWidth = (str: string) => { + let width = 0; + for (const c of str) width += FULLWIDTH_PATTERN.test(c) || REGIONS[c] ? 2 : 1; + return width; +}; +const padEnd = (s: string, len: number, chr: string) => + `${s}${chr.repeat(Math.max(0, len - chrWidth(s)))}`; + +const genTable = (rows: string[][]) => { + const widths = rows[0].map(() => 0); + for (const r of rows) + for (let i = 0; i < r.length; i++) widths[i] = Math.max(widths[i], chrWidth(r[i])); + let table = ''; + rows.forEach((r, i) => { + const cells = r.map((c, j) => padEnd(c, widths[j], ' ')).join(' | '); + table += `| ${cells} |\n`; + if (i === 0) table += `| ${r.map((_, j) => padEnd('', widths[j], '-')).join(' | ')} |\n`; + }); + return table; +}; + +const TAGS: Record string> = { + LANGUAGES: (currentLang: Lang) => + languages + .map((lang) => { + if (lang.code === currentLang.code) return lang.name; + return `[${lang.name}](https://github.com/paulmillr/encrypted-dns/${lang.code === DEFAULT_LANG ? '' : `blob/master/README.${lang.code}.md`})`; + }) + .join(' | '), + PROVIDERS_TABLE: (currentLang: Lang) => { + const rows: string[][] = [ + [ + currentLang.data.table_columns.name, + currentLang.data.table_columns.region, + currentLang.data.table_columns.censorship, + currentLang.data.table_columns.notes, + currentLang.data.table_columns.install_signed, + currentLang.data.table_columns.install_unsigned, + ], + ]; + const sorted = Array.from(providers) + .filter((p) => !p.hidden) + .sort((a, b) => a.id.localeCompare(b.id)); + for (const provider of sorted) { + const name = provider.names[currentLang.code] || provider.names[DEFAULT_LANG]; + const note = provider.notes[currentLang.code] || provider.notes[DEFAULT_LANG]; + const censorship = provider.censorship ? currentLang.data.yes : currentLang.data.no; + const regionEmoji = ( + Array.isArray(provider.region) ? provider.region : [provider.region || ''] + ) + .map((r) => REGIONS[r] || '') + .join(' ') + .trim(); + const unsignedLinks: string[] = []; + if (provider.formats?.unsigned?.https) + unsignedLinks.push(`[HTTPS][${provider.profile}-https]`); + if (provider.formats?.unsigned?.tls) unsignedLinks.push(`[TLS][${provider.profile}-tls]`); + const signedLinks: string[] = []; + if (provider.formats?.signed?.https) + signedLinks.push(`[HTTPS][${provider.profile}-https-signed]`); + if (provider.formats?.signed?.tls) signedLinks.push(`[TLS][${provider.profile}-tls-signed]`); + rows.push([ + `[${name}][${provider.id}]`, + regionEmoji, + censorship, + note, + signedLinks.join(', '), + unsignedLinks.join(', '), + ]); + } + return genTable(rows).trim(); + }, + PROVIDERS_LINKS: (_currentLang: Lang) => { + let res = ''; + const addLink = (p: Provider, https: boolean, signed?: boolean) => { + const file = providerFile(p, https, signed); + if (!fs.existsSync(outPath(file))) throw new Error(`missing: ${file}`); + res += `[${p.profile}-${https ? 'https' : 'tls'}${signed ? '-signed' : ''}]: ${REPO_RAW}/${file}\n`; + }; + for (const p of providers) { + if (p.hidden) continue; + if (p.website) res += `[${p.id}]: ${p.website}\n`; + if (p.formats?.unsigned?.https) addLink(p, true); + if (p.formats?.unsigned?.tls) addLink(p, false); + } + for (const p of providers) { + if (p.hidden) continue; + if (p.formats?.signed?.https) addLink(p, true, true); + if (p.formats?.signed?.tls) addLink(p, false, true); + } + return res; + }, +}; + +const processTemplate = (templateContent: string, lang: Lang) => { + let content = templateContent; + for (const [tag, handler] of Object.entries(TAGS)) { + const tagPattern = new RegExp(`<%${tag}%>`, 'g'); + if (content.match(tagPattern)) content = content.replace(tagPattern, handler(lang)); + } + return content; +}; + +const generateReadmes = () => { + for (const lang of languages) { + if (!fs.existsSync(lang.mdFile)) throw new Error(`Template file not found: ${lang.mdFile}`); + const tpl = fs.readFileSync(lang.mdFile, 'utf8'); + const processed = processTemplate(tpl, lang); + const out = lang.code === DEFAULT_LANG ? 'README.md' : `README.${lang.code}.md`; + fs.writeFileSync(path.join(OUTPUT_DIR, out), processed, 'utf8'); + console.log(`Generated ${out}`); + } +}; + +type NormalizeOpts = { + expectedProtocol?: 'https' | 'tls'; + serverAddresses?: string[]; + fullName?: string; +}; +const normalize = ( + x: ProfileCfg, + rel: string, + opts: NormalizeOpts = {} +): Required> & { + escapeXML: boolean; + signature?: string; +} => { + const escapeXML = x.escapeXML !== undefined ? x.escapeXML : true; + const protocolDefault = opts.expectedProtocol ? opts.expectedProtocol.toUpperCase() : ''; + const rootIdentifier = x.top?.identifier || 'com.paulmillr.apple-dns'; + const defaultPayloadDesc = (name: string) => `Configures device to use ${name}`; + const defaultTopDesc = (name: string) => `Adds the ${name} to Big Sur and iOS 14 based systems`; + const proto = (x.dns?.protocol || protocolDefault).toUpperCase(); + const fullNameRaw = x.fullName || opts.fullName || ''; + const fullNameWithProto = (() => { + if (!fullNameRaw) return ''; + if (/ over (HTTPS|TLS)$/.test(fullNameRaw)) return fullNameRaw; + if (proto === 'HTTPS' || proto === 'TLS') return `${fullNameRaw} over ${proto}`; + return fullNameRaw; + })(); + const baseName = x.PayloadDisplayName || fullNameWithProto || x.name || ''; + const topName = x.top?.displayName || x.topName || baseName; + const fullName = fullNameRaw || topName || baseName; + // Mixed-shape input (e.g. CLI `new.ts`) may provide only `dns` and flat payload/top fields. + // Only treat as fully-structured mode when all three nested blocks are present. + if (x.payload && x.top && x.dns) { + const dns = x.dns || { + protocol: protocolDefault, + server: x.ServerURLOrName || '', + addresses: x.ServerAddresses !== undefined ? x.ServerAddresses : opts.serverAddresses || [], + }; + return { + dns, + payload: x.payload!, + top: x.top!, + onDemandRules: x.onDemandRules || [], + certificates: x.certificates || [], + escapeXML, + signature: x.signature, + }; + } + return { + dns: x.dns || { + protocol: protocolDefault, + server: x.ServerURLOrName || '', + addresses: x.ServerAddresses !== undefined ? x.ServerAddresses : opts.serverAddresses || [], + }, + payload: { + description: x.PayloadDescription || defaultPayloadDesc(x.name || baseName), + displayName: baseName, + identifier: x.PayloadIdentifier || deterministicPayloadIdentifier(rootIdentifier, rel, 0), + uuid: x.PayloadUUID || deterministicUuid(rootIdentifier, 'payload', rel, 0), + type: x.PayloadType || 'com.apple.dnsSettings.managed', + version: x.PayloadVersion || 1, + organization: undefined, + prohibitDisablement: x.ProhibitDisablement !== undefined ? x.ProhibitDisablement : false, + }, + top: { + description: x.top?.description || defaultTopDesc(fullName), + displayName: x.top?.displayName || topName, + identifier: rootIdentifier, + removalDisallowed: x.top?.removalDisallowed !== undefined ? x.top.removalDisallowed : false, + scope: x.top?.scope || 'System', + type: x.top?.type || 'Configuration', + uuid: x.top?.uuid || deterministicUuid(rootIdentifier, 'root', rel), + version: x.top?.version || 1, + organization: x.top?.organization, + consentTextDefault: x.top?.consentTextDefault || x.ConsentTextDefault, + }, + onDemandRules: x.onDemandRules || [], + certificates: x.certificates || [], + escapeXML, + signature: x.signature, + }; +}; + +type PlistData = { TAG: 'data'; data: string }; +type PlistNode = + | string + | number + | boolean + | PlistData + | PlistNode[] + | Record; +const plistData = (x: string): PlistData => ({ TAG: 'data', data: x }); +const isPlistData = (x: PlistNode): x is PlistData => + typeof x === 'object' && !Array.isArray(x) && (x as PlistData).TAG === 'data'; +const plistNode = (x: PlistNode, level: number, esc: (s: string) => string): string => { + const pad = ' '.repeat(level); + if (typeof x === 'string') return `${pad}${esc(x)}\n`; + if (typeof x === 'number') return `${pad}${x}\n`; + if (typeof x === 'boolean') return `${pad}<${x ? 'true' : 'false'}/>\n`; + if (Array.isArray(x)) { + let out = `${pad}\n`; + for (const i of x) out += plistNode(i, level + 1, esc); + return `${out}${pad}\n`; + } + if (isPlistData(x)) return `${pad}${x.data}\n`; + let out = `${pad}\n`; + for (const [k, v] of Object.entries(x)) { + if (v === undefined) continue; + out += `${pad} ${k}\n`; + out += plistNode(v, level + 1, esc); + } + return `${out}${pad}\n`; +}; +const plistDoc = (root: PlistNode, rootLevel: number, esc: (s: string) => string) => + ` + + +${plistNode(root, rootLevel, esc)} +`; +const dnsNode = (d: DnsCfg): Record => ({ + DNSProtocol: d.protocol, + ...(d.addresses.length ? { ServerAddresses: d.addresses } : {}), + [d.server.startsWith('https://') ? 'ServerURL' : 'ServerName']: d.server, +}); +const rulesNode = (rules: Rule[]): PlistNode[] => + rules.map((r) => ({ + Action: r.Action, + ...(r.InterfaceTypeMatch ? { InterfaceTypeMatch: r.InterfaceTypeMatch } : {}), + ...(r.SSIDMatch && r.SSIDMatch.length ? { SSIDMatch: r.SSIDMatch } : {}), + ...(r.ActionParameters && r.ActionParameters.length + ? { + ActionParameters: r.ActionParameters.map((p) => ({ + DomainAction: p.DomainAction, + Domains: p.Domains, + })), + } + : {}), + })); +const certNodes = (certs: CertCfg[]): PlistNode[] => + certs.map((c) => ({ + PayloadCertificateFileName: c.fileName, + PayloadContent: plistData(certData(c.data)), + PayloadDisplayName: c.displayName, + PayloadIdentifier: c.identifier, + PayloadType: c.type || 'com.apple.security.pem', + PayloadUUID: c.uuid, + PayloadVersion: c.version || 1, + })); +const renderProfile = (cfg: ReturnType) => { + const p = cfg.payload; + const t = cfg.top; + const esc = cfg.escapeXML ? escapeXMLText : (s: string) => s; + const entry = (k: string, v: PlistNode | undefined): [string, PlistNode] | undefined => + v === undefined ? undefined : [k, v]; + const obj = (xs: Array<[string, PlistNode] | undefined>): Record => + Object.fromEntries(xs.filter(Boolean) as [string, PlistNode][]); + const payload = obj([ + ['DNSSettings', dnsNode(cfg.dns)], + entry('OnDemandRules', cfg.onDemandRules.length ? rulesNode(cfg.onDemandRules) : undefined), + ['PayloadDescription', p.description || ''], + ['PayloadDisplayName', p.displayName], + entry('PayloadOrganization', p.organization), + ['PayloadIdentifier', p.identifier], + ['PayloadType', p.type || 'com.apple.dnsSettings.managed'], + ['PayloadUUID', p.uuid], + ['PayloadVersion', p.version || 1], + entry('ProhibitDisablement', p.prohibitDisablement), + ]); + const payloadContent: PlistNode = [payload, ...certNodes(cfg.certificates)]; + const root = obj([ + ['PayloadContent', payloadContent], + ['PayloadDescription', t.description], + entry('ConsentText', t.consentTextDefault ? { default: t.consentTextDefault } : undefined), + ['PayloadDisplayName', t.displayName], + entry('PayloadOrganization', t.organization), + ['PayloadIdentifier', t.identifier], + entry('PayloadRemovalDisallowed', t.removalDisallowed), + entry('PayloadScope', t.scope), + ['PayloadType', t.type || 'Configuration'], + ['PayloadUUID', t.uuid], + ['PayloadVersion', t.version || 1], + ]); + return plistDoc(root, 0, esc); +}; + +export const generateSingle = (x: ProfileCfg) => { + const cfg = normalize(x, ''); + return renderProfile(cfg); +}; +export const normalizeProfile = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) => + normalize(x, rel, opts); +export const generateForRel = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) => { + const cfg = normalize(x, rel, opts); + return renderProfile(cfg); +}; +const generateSingleRel = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) => { + return generateForRel(x, rel, opts); +}; +const withDefaults = ( + cfg: ProfileCfg, + defaults: { serverAddresses?: string[]; fullName?: string } = {} +): ProfileCfg => { + const needAddrs = !!defaults.serverAddresses; + const needFullName = !!defaults.fullName; + if (!needAddrs && !needFullName) return cfg; + let out = cfg; + if (needFullName && out.fullName === undefined) out = { ...out, fullName: defaults.fullName }; + if (!needAddrs) return out; + if (out.dns) { + if (out.dns.addresses !== undefined) return out; + return { ...out, dns: { ...out.dns, addresses: defaults.serverAddresses } }; + } + if (out.ServerAddresses !== undefined) return out; + return { ...out, ServerAddresses: defaults.serverAddresses }; +}; + +const toBytes = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'utf8')); +const fromHex = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'hex')); +const fromSignature = (s: string): Uint8Array => { + const txt = s.trim(); + if (/^[0-9a-f]+$/i.test(txt) && txt.length % 2 === 0) return fromHex(txt); + throw new Error('expected compact signature in lowercase/uppercase hex'); +}; +let signerMaterialCache: { cert: string; chain: string } | undefined; +const signerMaterial = (): { cert: string; chain: string } => { + if (signerMaterialCache) return signerMaterialCache; + if (!fs.existsSync(CERT_PEM_FILE)) throw new Error(`missing signer cert: ${CERT_PEM_FILE}`); + if (!fs.existsSync(CHAIN_PEM_FILE)) throw new Error(`missing signer chain: ${CHAIN_PEM_FILE}`); + signerMaterialCache = { + cert: fs.readFileSync(CERT_PEM_FILE, 'utf8'), + chain: fs.readFileSync(CHAIN_PEM_FILE, 'utf8'), + }; + return signerMaterialCache; +}; +const verifyDetached = ( + p: Provider, + protocol: 'https' | 'tls', + parsed: ProfileCfg, + content: Uint8Array +) => { + if (!parsed.signature) return; + const compactSig = fromSignature(parsed.signature); + const mat = signerMaterial(); + const signed = CMS.compact.build(content, compactSig, mat.cert, mat.chain, SIGN_OPTS); + try { + CMS.verify(signed, { allowBER: true, checkSignatures: true, time: Date.now() }); + } catch (e) { + throw new Error(`${p.id}/${protocol}: signature verify failed (${(e as Error).message})`); + } +}; +const signedFromDetached = ( + p: Provider, + protocol: 'https' | 'tls', + isHttps: boolean, + parsed: ProfileCfg, + content: Uint8Array +) => { + if (!parsed.signature) return; + const compactSig = fromSignature(parsed.signature); + const mat = signerMaterial(); + const out = providerFile(p, isHttps, true); + const full = outPath(out); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const signed = CMS.compact.build(content, compactSig, mat.cert, mat.chain, SIGN_OPTS); + fs.writeFileSync(full, signed); + console.log(`Generated ${out}`); +}; + +const generateConfigs = () => { + const generate = ( + file: string, + parsed?: ProfileCfg, + where?: string, + expectedProtocol?: 'https' | 'tls', + defaults: { serverAddresses?: string[]; fullName?: string } = {} + ): Uint8Array | undefined => { + if (!parsed) return; + const input = withDefaults(parsed, defaults); + validateProfileInput(input, where || file, expectedProtocol); + const rel = file.startsWith('profiles/') ? file.slice('profiles/'.length) : file; + const raw = generateSingleRel(input, rel, { + expectedProtocol, + serverAddresses: defaults.serverAddresses, + fullName: defaults.fullName, + }); + const out = outPath(file); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, raw); + console.log(`Generated ${file}`); + return toBytes(raw); + }; + for (const p of providers) { + if (p.formats?.unsigned?.https) { + const content = generate( + providerFile(p, true), + p.https, + `${p.sourceFile || `provider:${p.id}`}:https`, + 'https', + { serverAddresses: p.ServerAddresses, fullName: p.fullName } + ); + if (content && p.https) { + verifyDetached(p, 'https', p.https, content); + signedFromDetached(p, 'https', true, p.https, content); + } + } + if (p.formats?.unsigned?.tls) { + const content = generate( + providerFile(p, false), + p.tls, + `${p.sourceFile || `provider:${p.id}`}:tls`, + 'tls', + { serverAddresses: p.ServerAddresses, fullName: p.fullName } + ); + if (content && p.tls) { + verifyDetached(p, 'tls', p.tls, content); + signedFromDetached(p, 'tls', false, p.tls, content); + } + } + } +}; + +const main = () => { + generateConfigs(); + generateSigned(); + generateReadmes(); +}; +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) main(); diff --git a/scripts/new.ts b/scripts/new.ts new file mode 100755 index 0000000..ce7affd --- /dev/null +++ b/scripts/new.ts @@ -0,0 +1,382 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { stdin as input, stdout as output } from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { + deterministicUuid, + generateSingle, + splitCsv, + validId, + validateDnsInput, + type ProfileCfg, +} from './build.ts'; +import { signFile } from './sign-single.ts'; + +type Proto = 'https' | 'tls'; + +type Input = { + name: string; + organizationName: string; + profileIdentifier: string; + protocol: Proto; + server: string; + addresses: string[]; + certs: string[]; + out: string; + description: string; + topDescription: string; + prohibitDisablement: boolean; + scope: string; + ca?: string; + priv_key?: string; + chain?: string; +}; +type PartialInput = Partial; + +const usage = () => { + console.error(`usage: + node scripts/new.ts --name --protocol --server --addresses [--organization ] [--profile-identifier ] [--certs ] [--out ] [--description ] [--top-description ] [--prohibit-disablement ] [--scope ] [--ca --priv_key [--chain ]] + +notes: + - if no args are passed, interactive mode starts + - --addresses may be empty only for https + - --prohibit-disablement: true prevents users from disabling encrypted DNS + - --scope: System applies to all users, User applies to current user + - PayloadRemovalDisallowed is fixed to false (same as dns-profile-generator UI flow)`); +}; + +const die = (msg: string): never => { + throw new Error(msg); +}; +const parseBool = (v: string, name: string) => { + if (v === 'true') return true; + if (v === 'false') return false; + return die(`${name}: expected true|false, got ${v}`); +}; +const parseYesNo = (v: string, name: string) => { + const x = v.toLowerCase(); + if (x === 'yes' || x === 'y') return true; + if (x === 'no' || x === 'n') return false; + return die(`${name}: expected yes|no, got ${v}`); +}; +const slug = (s: string) => + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') || 'dns-profile'; +const validate = (x: Input): Input => { + if (!x.name.trim()) die('name is required'); + if (!x.profileIdentifier.trim()) die('profile-identifier is required'); + if (!validId(x.profileIdentifier)) + die(`profile-identifier must match [A-Za-z0-9.-], got: ${x.profileIdentifier}`); + if (!x.out.trim()) die('out is required'); + if (x.scope !== 'System' && x.scope !== 'User') + die(`scope: expected System|User, got ${x.scope}`); + validateDnsInput({ protocol: x.protocol, server: x.server, addresses: x.addresses }, 'cli input'); + for (const f of x.certs) if (!fs.existsSync(f)) die(`missing file: ${f}`); + if ((x.ca && !x.priv_key) || (!x.ca && x.priv_key)) + die('signing requires both --ca and --priv_key'); + if (x.chain && (!x.ca || !x.priv_key)) die('--chain requires both --ca and --priv_key'); + if (x.ca && !fs.existsSync(x.ca)) die(`missing file: ${x.ca}`); + if (x.priv_key && !fs.existsSync(x.priv_key)) die(`missing file: ${x.priv_key}`); + if (x.chain && !fs.existsSync(x.chain)) die(`missing file: ${x.chain}`); + if (!x.out.endsWith('.mobileconfig')) x.out = `${x.out}.mobileconfig`; + return x; +}; +const withDefaults = (x: PartialInput): Input => { + const name = x.name || 'DNS profile'; + const protocol = x.protocol || 'https'; + return { + name, + protocol, + server: x.server || '', + addresses: x.addresses || [], + certs: x.certs || [], + organizationName: x.organizationName || '', + out: x.out || `${slug(name)}-${protocol}.mobileconfig`, + profileIdentifier: x.profileIdentifier || 'com.example.dns', + description: x.description || `Configures device to use ${name}`, + topDescription: x.topDescription || `Adds ${name} to Big Sur and iOS 14 based systems`, + prohibitDisablement: x.prohibitDisablement !== undefined ? x.prohibitDisablement : false, + scope: x.scope || 'System', + ca: x.ca, + priv_key: x.priv_key, + chain: x.chain, + }; +}; + +const asProfile = (x: Input): ProfileCfg => ({ + dns: { + protocol: x.protocol.toUpperCase(), + server: x.server, + addresses: x.addresses, + }, + PayloadDisplayName: x.name, + PayloadDescription: x.description, + PayloadIdentifier: `${x.profileIdentifier}.dns`, + PayloadUUID: deterministicUuid(x.profileIdentifier, 'payload', 'cli', 0), + ProhibitDisablement: x.prohibitDisablement, + top: { + displayName: x.name, + description: x.topDescription, + identifier: x.profileIdentifier, + uuid: deterministicUuid(x.profileIdentifier, 'root', 'cli'), + removalDisallowed: false, + scope: x.scope, + organization: x.organizationName || undefined, + }, + certificates: x.certs.map((f, i) => { + const data = fs.readFileSync(f, 'utf8'); + const name = path.basename(f).replace(/\.(pem|cer|crt)$/i, ''); + return { + fileName: path.basename(f), + data, + displayName: name || `Certificate ${i + 1}`, + identifier: `${x.profileIdentifier}.cert.${i}`, + uuid: deterministicUuid(x.profileIdentifier, 'payload', 'cert', i + 1), + }; + }), + escapeXML: true, +}); + +const parseArgs = (argv: string[]): PartialInput => { + if (!argv.length) return {}; + const out: Record = {}; + const allowed = new Set([ + 'name', + 'organization', + 'profile-identifier', + 'protocol', + 'server', + 'addresses', + 'certs', + 'out', + 'description', + 'top-description', + 'prohibit-disablement', + 'scope', + 'ca', + 'priv_key', + 'chain', + ]); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith('--')) die(`unexpected arg: ${a}`); + const k = a.slice(2); + if (!allowed.has(k)) die(`unknown option: --${k}`); + const v = argv[i + 1]; + if (!v || v.startsWith('--')) die(`missing value for --${k}`); + out[k] = v; + i++; + } + const protocol = out.protocol as Proto | undefined; + const name = out.name; + return { + name, + organizationName: out.organization, + profileIdentifier: out['profile-identifier'], + protocol, + server: out.server, + addresses: out.addresses !== undefined ? splitCsv(out.addresses) : undefined, + certs: out.certs !== undefined ? splitCsv(out.certs) : undefined, + description: out.description, + topDescription: out['top-description'], + out: out.out, + prohibitDisablement: out['prohibit-disablement'] + ? parseBool(out['prohibit-disablement'], 'prohibit-disablement') + : undefined, + scope: out.scope, + ca: out.ca, + priv_key: out.priv_key, + chain: out.chain, + }; +}; + +const askRequired = async (seed: PartialInput = {}): Promise => { + const rl = createInterface({ input, output }); + const q = async (prompt: string, def = '') => { + const txt = await rl.question(def ? `${prompt} [${def}]: ` : `${prompt}: `); + return txt.trim() || def; + }; + const retry = async (prompt: string, def: string, parse: (v: string) => T): Promise => { + let err = ''; + while (true) { + try { + const label = err ? `${prompt} (error: ${err})` : prompt; + return parse(await q(label, def)); + } catch (e) { + err = (e as Error).message; + } + } + }; + try { + const name = + seed.name || + (await retry('Display name', '', (v) => { + if (!v) throw new Error('name is required'); + return v; + })); + const protocol = + seed.protocol || + (await retry('Protocol (https|tls)', 'https', (v) => { + const x = v.toLowerCase(); + if (x !== 'https' && x !== 'tls') throw new Error(`protocol: expected https|tls, got ${v}`); + return x as Proto; + })); + const server = + seed.server || + (await retry( + protocol === 'https' ? 'Server URL (https://...)' : 'Server hostname', + '', + (v) => { + if (!v) throw new Error('server is required'); + validateDnsInput({ protocol, server: v, addresses: [] }, 'Server'); + return v; + } + )); + const addresses = + seed.addresses || + (await retry( + 'Server addresses (comma-separated IPs, optional for https)', + '', + (v) => { + const arr = splitCsv(v); + validateDnsInput({ protocol, server, addresses: arr }, 'Server addresses'); + return arr; + } + )); + return { + ...seed, + name, + protocol, + server, + addresses, + }; + } finally { + rl.close(); + } +}; + +const askFull = async (): Promise => { + const rl = createInterface({ input, output }); + const q = async (prompt: string, def = '') => { + const txt = await rl.question(def ? `${prompt} [${def}]: ` : `${prompt}: `); + return txt.trim() || def; + }; + const retry = async (prompt: string, def: string, parse: (v: string) => T): Promise => { + let err = ''; + while (true) { + try { + const label = err ? `${prompt} (error: ${err})` : prompt; + return parse(await q(label, def)); + } catch (e) { + err = (e as Error).message; + } + } + }; + try { + const name = await retry('Display name', '', (v) => { + if (!v) throw new Error('name is required'); + return v; + }); + const protocol = await retry('Protocol (https|tls)', 'https', (v) => { + const x = v.toLowerCase(); + if (x !== 'https' && x !== 'tls') throw new Error(`protocol: expected https|tls, got ${v}`); + return x as Proto; + }); + const server = await retry( + protocol === 'https' ? 'Server URL (https://...)' : 'Server hostname', + '', + (v) => { + if (!v) throw new Error('server is required'); + validateDnsInput({ protocol, server: v, addresses: [] }, 'Server'); + return v; + } + ); + const addresses = await retry( + 'Server addresses (comma-separated IPs, optional for https)', + '', + (v) => { + const arr = splitCsv(v); + validateDnsInput({ protocol, server, addresses: arr }, 'Server addresses'); + return arr; + } + ); + const sign = await retry('Sign profile? (yes/no)', 'no', (v) => parseYesNo(v, 'sign')); + const organizationName = await q('Organization name (optional)'); + const profileIdentifier = await retry('Profile identifier', 'com.example.dns', (v) => { + if (!validId(v)) throw new Error(`profile-identifier must match [A-Za-z0-9.-], got: ${v}`); + return v; + }); + const out = await q('Output file', `${slug(name)}-${protocol}.mobileconfig`); + const prohibitDisablement = await retry( + 'Prohibit disabling encrypted DNS? (true|false)', + 'false', + (v) => parseBool(v.toLowerCase(), 'ProhibitDisablement') + ); + const scope = await retry('Payload scope (System|User)', 'System', (v) => { + const x = v[0]?.toUpperCase() + v.slice(1).toLowerCase(); + if (x !== 'System' && x !== 'User') throw new Error(`scope: expected System|User, got ${v}`); + return x; + }); + let ca = ''; + let priv_key = ''; + let chain = ''; + if (sign) { + ca = await retry('Signer cert path', '', (v) => { + if (!v) throw new Error('signer cert path is required'); + if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`); + return v; + }); + priv_key = await retry('Signer private key path', '', (v) => { + if (!v) throw new Error('signer private key path is required'); + if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`); + return v; + }); + chain = await retry('Signer chain path (optional)', '', (v) => { + if (!v) return ''; + if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`); + return v; + }); + } + return { + name, + protocol, + server, + addresses, + organizationName, + profileIdentifier, + out, + prohibitDisablement, + scope, + ca: ca || undefined, + priv_key: priv_key || undefined, + chain: chain || undefined, + }; + } finally { + rl.close(); + } +}; + +const main = async () => { + const argv = process.argv.slice(2); + if (argv.includes('--help') || argv.includes('-h')) { + usage(); + return; + } + const parsed = parseArgs(argv); + const cfg = validate(withDefaults(argv.length ? await askRequired(parsed) : await askFull())); + const xml = generateSingle(asProfile(cfg)); + const out = path.resolve(cfg.out); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, xml); + console.log(out); + if (cfg.ca && cfg.priv_key) + console.log(signFile({ ca: cfg.ca, priv_key: cfg.priv_key, chain: cfg.chain, input: out })); +}; + +main().catch((e) => { + console.error((e as Error).message || e); + process.exit(1); +}); diff --git a/scripts/sign.ts b/scripts/sign.ts new file mode 100755 index 0000000..7f40018 --- /dev/null +++ b/scripts/sign.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { CMS } from 'micro-key-producer/x509.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + generateForRel, + providerFile, + SIGN_OPTS, + validateProfileInput, + type ProfileCfg, +} from './build.ts'; + +type Provider = { + id: string; + name?: string; + file?: string; + fullName?: string; + ServerAddresses?: string[]; + https?: ProfileCfg; + tls?: ProfileCfg; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.join(__dirname, '..'); +const PROVIDERS = path.join(ROOT, 'src'); +const CERT_PEM = path.join(ROOT, 'certs', 'cert.pem'); +const CHAIN_PEM = path.join(ROOT, 'certs', 'chain.pem'); +const PRIVKEY_PEM = path.join(ROOT, 'certs', 'privkey.pem'); +const USAGE = `node sign.ts expects following files to exist: + +* ${path.relative(ROOT, CERT_PEM)}: pubkey certificate +* ${path.relative(ROOT, CHAIN_PEM)}: pubkey certificate chain +* ${path.relative(ROOT, PRIVKEY_PEM)}: PRIVATE key used to sign requests (never share this) +`; + +const withDefaults = ( + cfg: ProfileCfg, + defaults: { serverAddresses?: string[]; fullName?: string } = {} +): ProfileCfg => { + const needAddrs = !!defaults.serverAddresses; + const needFullName = !!defaults.fullName; + if (!needAddrs && !needFullName) return cfg; + let out = cfg; + if (needFullName && out.fullName === undefined) out = { ...out, fullName: defaults.fullName }; + if (!needAddrs) return out; + if (out.dns) { + if (out.dns.addresses !== undefined) return out; + return { ...out, dns: { ...out.dns, addresses: defaults.serverAddresses } }; + } + if (out.ServerAddresses !== undefined) return out; + return { ...out, ServerAddresses: defaults.serverAddresses }; +}; + +const main = () => { + [PRIVKEY_PEM, CERT_PEM, CHAIN_PEM].forEach(filepath => { + if (!fs.existsSync(filepath)) throw new Error(USAGE); + }); + + const key = fs.readFileSync(PRIVKEY_PEM, 'utf8'); + const cert = fs.readFileSync(CERT_PEM, 'utf8'); + const chain = fs.readFileSync(CHAIN_PEM, 'utf8'); + const files = fs + .readdirSync(PROVIDERS) + .filter((f) => f.endsWith('.json')) + .sort(); + const enc = new TextEncoder(); + let updated = 0; + + for (const fileName of files) { + const full = path.join(PROVIDERS, fileName); + const provider = JSON.parse(fs.readFileSync(full, 'utf8')) as Provider; + let changed = false; + for (const protocol of ['https', 'tls'] as const) { + const src = provider[protocol]; + if (!src) continue; + const input = withDefaults(src, { + serverAddresses: provider.ServerAddresses, + fullName: provider.fullName, + }); + validateProfileInput(input, `${fileName}:${protocol}`, protocol); + const relPath = providerFile(provider, protocol === 'https').replace(/^profiles\//, ''); + const raw = generateForRel(input, relPath, { + expectedProtocol: protocol, + serverAddresses: provider.ServerAddresses, + fullName: provider.fullName, + }); + const content = enc.encode(raw); + const compact = CMS.compact.sign(content, cert, key, SIGN_OPTS); + const signed = CMS.compact.build(content, compact, cert, chain, SIGN_OPTS); + CMS.verify(signed, { allowBER: true, checkSignatures: true, time: Date.now() }); + const sigHex = Buffer.from(compact).toString('hex'); + if (src.signature !== sigHex) { + src.signature = sigHex; + changed = true; + } + } + if (!changed) continue; + fs.writeFileSync(full, `${JSON.stringify(provider, undefined, 4)}\n`); + updated++; + console.log(`Updated ${fileName}`); + } + console.log(`${updated} mobileconfig files updated`); + console.log(`signing done`); +}; + +main();