Update readme, add certs dir

This commit is contained in:
Paul Miller
2026-02-27 08:08:40 +00:00
parent 82b80b56d1
commit 140fa1f6d2
6 changed files with 109 additions and 331 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
certs/cert.pem
certs/chain.pem
certs/fullchain.pem
certs/privkey.pem
certs/README

View File

@@ -2,19 +2,7 @@ English | [简体中文](https://github.com/paulmillr/encrypted-dns/blob/master/
# encrypted-dns-configs
Configuration profiles for [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) and [DNS over TLS](https://en.wikipedia.org/wiki/DNS_over_TLS). Check out the article for more info: [paulmillr.com/posts/encrypted-dns/](https://paulmillr.com/posts/encrypted-dns/). To add a new provider, or edit an existing one, edit json files in `src` directory.
## Known issues
1. Some apps and protocols will ignore encrypted-dns:
- Firefox in specific regions, App Store in all regions. [More info](https://github.com/paulmillr/encrypted-dns/issues/22)
- iCloud Private Relay, VPN clients
- Little Snitch, LuLu
- DNS-related CLI tools: `host`, `dig`, `nslookup` etc.
2. [Wi-Fi captive portals](https://en.wikipedia.org/wiki/Captive_portal) in cafes, hotels, airports are exempted by Apple from eDNS rules; to simplify authentication - this is ok
3. TLS DNS is easier for providers to block, because it uses non-standard port 853.
[More info](https://security.googleblog.com/2022/07/dns-over-http3-in-android.html)
4. e-dns over TOR could be better privacy-wise, but we don't have this for now.
Configuration profiles for [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) and [DNS over TLS](https://en.wikipedia.org/wiki/DNS_over_TLS). Check out the article for more info: [paulmillr.com/posts/encrypted-dns/](https://paulmillr.com/posts/encrypted-dns/). To add a new provider, or edit an existing one: see [#contributing](#contributing).
## Usage
@@ -22,20 +10,20 @@ Install / download profile (`.mobileconfig` file) from a table below. After that
iPhones, iPads:
1. Open the mobileconfig file in GitHub by using Safari (other browsers will just download the file and won't ask for installation)
1. Open the file by using Safari (other browsers will just download the file and won't ask for installation)
2. Tap on "Allow" button. The profile should download.
3. Go to **System Settings => General => VPN, DNS & Device Management**, select downloaded profile and tap the "Install" button.
Mac:
1. Ensure the downloaded file has proper extension: NAME.mobileconfig, not NAME.mobileconfig.txt.
2. Choose Apple menu > System Settings, click Privacy and Security in the sidebar, then click Profiles on the right. (You may need to scroll down.)
3. You may be asked to supply your password or other information during installation.
4. In the Downloaded section, double-click the profile. Review the profile contents then click Continue, Install or Enroll to install the profile. If an earlier version of a profile is already installed on your Mac, the settings in the updated version replace the previous ones.
2. Choose Apple menu > System Settings, click Privacy and Security in the sidebar, then click Profiles on the right.
You may need to scroll down. You may be asked to supply your password or other information during installation.
3. In the Downloaded section, double-click the profile. Review the profile contents then click Continue, Install or Enroll to install the profile. If an earlier version of a profile is already installed on your Mac, the settings in the updated version replace the previous ones.
## Providers
`Censorship=yes` (also known as "filtering") means the profile will not send true information about `hostname=IP` relation for some hosts.
Censorship (also known as "filtering") means the profile will not send true information about `hostname=IP` relation for some hosts.
| Name | Region | Censorship | Notes | Install | Install (unsigned) |
| ------------------------------------------------------------------------------------ | ------ | ---------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
@@ -77,17 +65,52 @@ Mac:
| [Quad9 Unfiltered][quad9] | 🇨🇭 | No | Operated by Quad9 Foundation. | [HTTPS][quad9-profile-unfiltered-https-signed], [TLS][quad9-profile-unfiltered-tls-signed] | [HTTPS][quad9-profile-unfiltered-https], [TLS][quad9-profile-unfiltered-tls] |
| [Tiarap][tiarap] | 🇸🇬 🇺🇸 | Yes | Operated by Tiarap Inc. Blocks ads, tracking, phising & malware | [HTTPS][tiarap-profile-https-signed], [TLS][tiarap-profile-tls-signed] | [HTTPS][tiarap-profile-https], [TLS][tiarap-profile-tls] |
## Signed Profiles
## Known issues
To verify resolver IPs and hostnames, compare mobileconfig files to their documentation URLs. Internal workings of the profiles are described on [developer.apple.com](https://developer.apple.com/documentation/devicemanagement/dnssettings). In order to verify signed mobileconfigs, you will need to download them to your computer and open them in a text editor, because signing profiles makes GitHub think that they are binary files.
1. Some apps and protocols will ignore encrypted-dns:
- Firefox in specific regions, App Store in all regions. [More info](https://github.com/paulmillr/encrypted-dns/issues/22)
- iCloud Private Relay, VPN clients
- Little Snitch, LuLu
- DNS-related CLI tools: `host`, `dig`, `nslookup` etc.
2. [Wi-Fi captive portals](https://en.wikipedia.org/wiki/Captive_portal) in cafes, hotels, airports are exempted by Apple from eDNS rules; to simplify authentication - this is ok
3. TLS DNS is easier for providers to block, because it uses non-standard port 853.
[More info](https://security.googleblog.com/2022/07/dns-over-http3-in-android.html)
4. e-dns over TOR could be better privacy-wise, but we don't have this for now.
## On demand activation
## Contributing
You can optionally exclude some trusted Wi-Fi networks where you don't want to use encrypted DNS. To do so, add your SSIDs in the [OnDemandRules](https://github.com/paulmillr/encrypted-dns/blob/master/profiles/template-on-demand.mobileconfig#L22-L38) section inside the `PayloadContent` dictionary of a profile. Note: you can't edit signed profiles.
- **To add / edit a profile:** edit json files in `src` directory.
- **To verify resolver IPs / hostnames:** compare mobileconfig files to their original websites (open files in a text editor).
- Check out [developer.apple.com](https://developer.apple.com/documentation/devicemanagement/dnssettings) for more docs.
- **On demand activation:** You can optionally exclude some trusted Wi-Fi networks where you don't want to use encrypted DNS. To do so, add your SSIDs in the [OnDemandRules](https://github.com/paulmillr/encrypted-dns/blob/master/profiles/template-on-demand.mobileconfig#L22-L38) section inside the `PayloadContent` dictionary of a profile.
## Contributing a new profile
### Scripts
To add a new provider, or edit an existing one, edit json files in `src` directory.
- `npm run build` - re-build profiles, signed profiles, READMEs
- `npm run sign` - re-sign all profiles (updates `signature` field) using an ECC SSL certificate.
- Signing is done using [key-producer](https://github.com/paulmillr/micro-key-producer)
- Let's Encrypt free certificates are OK, but [expire in 45 days](https://letsencrypt.org/2026/02/24/rate-limits-45-day-certs).
- Expects following files to be present in `certs` subdirectory:
```
`privkey.pem` : the private key for your certificate.
`fullchain.pem`: the certificate file used in most server software.
`chain.pem` : used for OCSP stapling in Nginx >=1.3.7.
`cert.pem`
```
- `npm run new` - interactively creates new profile from CLI options. Can also be ran with flags.
- `scripts/new.test.ts` includes CLI snapshot tests and a PTY interactive flow test.
- PTY test runs by default; set `NEW_TEST_PTY=0` to opt out.
- `node scripts/sign-single.ts --ca cert.pem --priv_key key.pem [--chain chain.pem] path.mobileconfig` - sings single mobileconfig
- `node scripts/sign-single-openssl.ts --ca cert.pem --priv_key key.pem [--chain chain.pem] path.mobileconfig` Sign one `.mobileconfig` using OpenSSL.
- Uses `-nosmimecap` to match local CMS signing policy.
- `node scripts/detach.ts signed.mobileconfig` - detach CMS signature from signed profile and print PEM to stdout.
- `node test/sign-single.test.ts` - Parity check for `sign-single.ts` vs `sign-single-openssl.sh`.
- Runs under `npm run test`.
- Generates temporary test root/signer certificates and keys via OpenSSL.
- Signs the same profile with `scripts/sign.ts` and `scripts/sign_openssl.sh`.
- Verifies detached content and embedded certificate set parity.
[360-dns]: https://sdns.360.net/dnsPublic.html
[360-dns-profile-https]: https://github.com/paulmillr/encrypted-dns/raw/master/profiles/360-https.mobileconfig

0
certs/.gitkeep Normal file
View File

View File

@@ -1,275 +0,0 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const LANGUAGES_DIR = path.join(__dirname, 'src-languages');
const PROVIDERS_PATH = path.join(__dirname, 'src');
const DEFAULT_LANG = 'en';
const OUTPUT_DIR = __dirname;
const REPO_RAW = 'https://github.com/paulmillr/encrypted-dns/raw/master';
const REGIONS = {
US: '🇺🇸',
CN: '🇨🇳',
RU: '🇷🇺',
NL: '🇳🇱',
DE: '🇩🇪',
SG: '🇸🇬',
CA: '🇨🇦',
FR: '🇫🇷',
CH: '🇨🇭',
SE: '🇸🇪',
CZ: '🇨🇿',
};
const providerFile = (p, https, signed) => {
const postfix = (p, https) => {
if (https) {
if (p.doh && p.doh !== true) return `doh${p.doh}`;
if (p.doh) return 'doh';
return 'https';
} else {
if (p.doh && p.doh !== true) return `dot${p.doh}`;
if (p.doh) return 'dot';
return 'tls';
}
};
const name = p.name || p.id;
return `${signed ? 'signed' : 'profiles'}/${name}-${postfix(p, https)}.mobileconfig`;
};
const languages = fs
.readdirSync(LANGUAGES_DIR)
.filter((f) => f.endsWith('.json'))
.sort()
.map((f) => {
const p = path.join(LANGUAGES_DIR, f);
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
return {
code: data.code,
name: data.name,
mdFile: p.replace('.json', '.md'),
jsonFile: p,
data: data,
};
});
const providers = fs
.readdirSync(PROVIDERS_PATH)
.sort()
.map((i) => JSON.parse(fs.readFileSync(`${PROVIDERS_PATH}/${i}`)))
.map((i) => {
const unsigned = {
https: !!i.https || fs.existsSync(providerFile(i, true)),
tls: !!i.tls || fs.existsSync(providerFile(i, false)),
};
const signed = {
https: unsigned.https && fs.existsSync(providerFile(i, true, true)),
tls: unsigned.tls && fs.existsSync(providerFile(i, false, true)),
};
return { ...i, formats: { unsigned, signed } };
});
const FULLWIDTH_PATTERN =
/[\u1100-\u115F\u2329\u232A\u2E80-\u303E\u3040-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/u;
function chrWidth(str) {
let width = 0;
for (const char of str) width += FULLWIDTH_PATTERN.test(char) || REGIONS[char] ? 2 : 1;
return width;
}
const padEnd = (s, len, chr) => `${s}${chr.repeat(Math.max(0, len - chrWidth(s)))}`;
const genTable = (rows) => {
// first row is header
const widths = rows[0].map((i) => 0);
// Collect cell widths
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((c, j) => padEnd('', widths[j], '-')).join(' | ')} |\n`;
});
return table;
};
const TAGS = {
// Language selection header
LANGUAGES: (currentLang) => {
return 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) => {
const rows = [
// header
[
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).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(' ');
const unsignedLinks = [];
if (provider.formats.unsigned && provider.formats.unsigned.https)
unsignedLinks.push(`[HTTPS][${provider.profile}-https]`);
if (provider.formats.unsigned && provider.formats.unsigned.tls)
unsignedLinks.push(`[TLS][${provider.profile}-tls]`);
const signedLinks = [];
if (provider.formats.signed && provider.formats.signed.https)
signedLinks.push(`[HTTPS][${provider.profile}-https-signed]`);
if (provider.formats.signed && 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) => {
let res = '';
const addLink = (p, https, signed) => {
const file = providerFile(p, https, signed);
if (!fs.existsSync(`./${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.website) res += `[${p.id}]: ${p.website}\n`;
if (p.formats.unsigned.https) addLink(p, true);
if (p.formats.unsigned.tls) addLink(p, false);
}
// signed
for (const p of providers) {
if (p.formats.signed.https) addLink(p, true, true);
if (p.formats.signed.tls) addLink(p, false, true);
}
return res;
},
};
function processTemplate(templateContent, 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;
}
function 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}`);
}
}
function generateSingle(x) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>${x.DNSProtocol}</string>
<key>ServerAddresses</key>
<array>
${x.ServerAddresses.map((i) => `\t\t\t\t\t<string>${i}</string>`).join('\n')}
</array>
<key>${!x.ServerURLOrName.startsWith('https://') ? 'ServerName' : 'ServerURL'}</key>
<string>${x.ServerURLOrName}</string>
</dict>
<key>PayloadDescription</key>
<string>Configures device to use ${x.name}</string>
<key>PayloadDisplayName</key>
<string>${x.PayloadDisplayName}</string>
<key>PayloadIdentifier</key>
<string>${x.PayloadIdentifier}</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>${x.PayloadUUID}</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>ProhibitDisablement</key>
<false/>
</dict>
</array>
<key>PayloadDescription</key>
<string>Adds the ${x.fullName} to Big Sur and iOS 14 based systems</string>
<key>PayloadDisplayName</key>
<string>${x.topName}</string>
<key>PayloadIdentifier</key>
<string>com.paulmillr.apple-dns</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>${x.TopPayloadUUID}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`;
}
function generateConfigs() {
function generate(file, parsed) {
if (!parsed) return;
fs.writeFileSync(file, generateSingle(parsed));
console.log(`Generated ${file}`);
}
for (const p of providers) {
if (p.formats.unsigned.https) generate(providerFile(p, true), p.https);
if (p.formats.unsigned.tls) generate(providerFile(p, false), p.tls);
}
}
// Small utility to rewrite config structure
function patchConfigs() {
for (const f of fs.readdirSync(`./src/`)) {
const path = `./src/${f}`;
const json = JSON.parse(fs.readFileSync(path, 'utf8'));
fs.writeFileSync(path, JSON.stringify(json, null, 4));
}
}
function main() {
//patchConfigs();
generateReadmes();
generateConfigs();
}
main();

View File

@@ -4,8 +4,10 @@
"type": "module",
"scripts": {
"build:clean": "rm -f signed/*.mobileconfig profiles/*.mobileconfig README*.md",
"build": "npm run build:clean && node scripts/generate.ts",
"test": "node --experimental-strip-types --test scripts/new.test.ts scripts/sign.test.ts",
"build": "npm run build:clean && node scripts/build.ts",
"sign": "node scripts/sign.ts",
"new": "node scripts/new.ts",
"test": "node --experimental-strip-types --test scripts/new.test.ts scripts/sign-single.test.ts",
"format": "prettier --write \"src/*.json\" scripts/*.ts *.ts"
},
"dependencies": {

View File

@@ -2,7 +2,30 @@
# encrypted-dns-configs
Configuration profiles for [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) and [DNS over TLS](https://en.wikipedia.org/wiki/DNS_over_TLS). Check out the article for more info: [paulmillr.com/posts/encrypted-dns/](https://paulmillr.com/posts/encrypted-dns/). To add a new provider, or edit an existing one, edit json files in `src` directory.
Configuration profiles for [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) and [DNS over TLS](https://en.wikipedia.org/wiki/DNS_over_TLS). Check out the article for more info: [paulmillr.com/posts/encrypted-dns/](https://paulmillr.com/posts/encrypted-dns/). To add a new provider, or edit an existing one: see [#contributing](#contributing).
## Usage
Install / download profile (`.mobileconfig` file) from a table below. After that:
iPhones, iPads:
1. Open the file by using Safari (other browsers will just download the file and won't ask for installation)
2. Tap on "Allow" button. The profile should download.
3. Go to **System Settings => General => VPN, DNS & Device Management**, select downloaded profile and tap the "Install" button.
Mac:
1. Ensure the downloaded file has proper extension: NAME.mobileconfig, not NAME.mobileconfig.txt.
2. Choose Apple menu > System Settings, click Privacy and Security in the sidebar, then click Profiles on the right.
You may need to scroll down. You may be asked to supply your password or other information during installation.
3. In the Downloaded section, double-click the profile. Review the profile contents then click Continue, Install or Enroll to install the profile. If an earlier version of a profile is already installed on your Mac, the settings in the updated version replace the previous ones.
## Providers
Censorship (also known as "filtering") means the profile will not send true information about `hostname=IP` relation for some hosts.
<%PROVIDERS_TABLE%>
## Known issues
@@ -16,39 +39,39 @@ Configuration profiles for [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_ov
[More info](https://security.googleblog.com/2022/07/dns-over-http3-in-android.html)
4. e-dns over TOR could be better privacy-wise, but we don't have this for now.
## Usage
## Contributing
Install / download profile (`.mobileconfig` file) from a table below. After that:
- **To add / edit a profile:** edit json files in `src` directory.
- **To verify resolver IPs / hostnames:** compare mobileconfig files to their original websites (open files in a text editor).
- Check out [developer.apple.com](https://developer.apple.com/documentation/devicemanagement/dnssettings) for more docs.
- **On demand activation:** You can optionally exclude some trusted Wi-Fi networks where you don't want to use encrypted DNS. To do so, add your SSIDs in the [OnDemandRules](https://github.com/paulmillr/encrypted-dns/blob/master/profiles/template-on-demand.mobileconfig#L22-L38) section inside the `PayloadContent` dictionary of a profile.
iPhones, iPads:
### Scripts
1. Open the mobileconfig file in GitHub by using Safari (other browsers will just download the file and won't ask for installation)
2. Tap on "Allow" button. The profile should download.
3. Go to **System Settings => General => VPN, DNS & Device Management**, select downloaded profile and tap the "Install" button.
- `npm run build` - re-build profiles, signed profiles, READMEs
- `npm run sign` - re-sign all profiles (updates `signature` field) using an ECC SSL certificate.
- Signing is done using [key-producer](https://github.com/paulmillr/micro-key-producer)
- Let's Encrypt free certificates are OK, but [expire in 45 days](https://letsencrypt.org/2026/02/24/rate-limits-45-day-certs).
- Expects following files to be present in `certs` subdirectory:
Mac:
```
`privkey.pem` : the private key for your certificate.
`fullchain.pem`: the certificate file used in most server software.
`chain.pem` : used for OCSP stapling in Nginx >=1.3.7.
`cert.pem`
```
1. Ensure the downloaded file has proper extension: NAME.mobileconfig, not NAME.mobileconfig.txt.
2. Choose Apple menu > System Settings, click Privacy and Security in the sidebar, then click Profiles on the right. (You may need to scroll down.)
3. You may be asked to supply your password or other information during installation.
4. In the Downloaded section, double-click the profile. Review the profile contents then click Continue, Install or Enroll to install the profile. If an earlier version of a profile is already installed on your Mac, the settings in the updated version replace the previous ones.
## Providers
`Censorship=yes` (also known as "filtering") means the profile will not send true information about `hostname=IP` relation for some hosts.
<%PROVIDERS_TABLE%>
## Signed Profiles
To verify resolver IPs and hostnames, compare mobileconfig files to their documentation URLs. Internal workings of the profiles are described on [developer.apple.com](https://developer.apple.com/documentation/devicemanagement/dnssettings). In order to verify signed mobileconfigs, you will need to download them to your computer and open them in a text editor, because signing profiles makes GitHub think that they are binary files.
## On demand activation
You can optionally exclude some trusted Wi-Fi networks where you don't want to use encrypted DNS. To do so, add your SSIDs in the [OnDemandRules](https://github.com/paulmillr/encrypted-dns/blob/master/profiles/template-on-demand.mobileconfig#L22-L38) section inside the `PayloadContent` dictionary of a profile. Note: you can't edit signed profiles.
## Contributing a new profile
To add a new provider, or edit an existing one, edit json files in `src` directory.
- `npm run new` - interactively creates new profile from CLI options. Can also be ran with flags.
- `scripts/new.test.ts` includes CLI snapshot tests and a PTY interactive flow test.
- PTY test runs by default; set `NEW_TEST_PTY=0` to opt out.
- `node scripts/sign-single.ts --ca cert.pem --priv_key key.pem [--chain chain.pem] path.mobileconfig` - sings single mobileconfig
- `node scripts/sign-single-openssl.ts --ca cert.pem --priv_key key.pem [--chain chain.pem] path.mobileconfig` Sign one `.mobileconfig` using OpenSSL.
- Uses `-nosmimecap` to match local CMS signing policy.
- `node scripts/detach.ts signed.mobileconfig` - detach CMS signature from signed profile and print PEM to stdout.
- `node test/sign-single.test.ts` - Parity check for `sign-single.ts` vs `sign-single-openssl.sh`.
- Runs under `npm run test`.
- Generates temporary test root/signer certificates and keys via OpenSSL.
- Signs the same profile with `scripts/sign.ts` and `scripts/sign_openssl.sh`.
- Verifies detached content and embedded certificate set parity.
<%PROVIDERS_LINKS%>