Skip to content

Commit 578b596

Browse files
committed
work
1 parent 5998a44 commit 578b596

9 files changed

Lines changed: 376 additions & 4 deletions

File tree

packages/tooling/https-local/README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Generate locally trusted HTTPS certificates for local development.
55
🔒 Certificates trusted by your operating system and browsers
66
🌐 Perfect for local HTTPS development
77
🖥️ Works on macOS, Linux, and Windows
8+
📱 Trusted by iOS simulators too
89
⚡ Simple CLI and JavaScript API
910

1011
## Table of Contents
@@ -14,6 +15,7 @@ Generate locally trusted HTTPS certificates for local development.
1415
- [Quick Start](#quick-start)
1516
- [CLI](#cli)
1617
- [init](#init)
18+
- [iOS simulator](#ios-simulator)
1719
- [generate](#generate)
1820
- [cleanup](#cleanup)
1921
- [Certificate Expiration](#certificate-expiration)
@@ -59,7 +61,7 @@ const server = createServer(
5961
npx @jsenv/https-local init
6062
```
6163

62-
Installs a root certificate authority, trusts it in your OS and browsers, and ensures `localhost` is mapped to `127.0.0.1` in your hosts file. Safe to re-run — subsequent runs report the current status.
64+
Installs a root certificate authority, trusts it in your OS, your browsers and the [iOS simulators](#ios-simulator) currently booted, and ensures `localhost` is mapped to `127.0.0.1` in your hosts file. Safe to re-run — subsequent runs report the current status.
6365

6466
<details>
6567
<summary>First execution (macOS)</summary>
@@ -76,6 +78,11 @@ Password:
7678
✔ certificate added to mac keychain
7779
Adding certificate to firefox...
7880
✔ certificate added to Firefox
81+
Check if certificate is in iOS simulator "iPhone 17"...
82+
ℹ certificate not found in iOS simulator "iPhone 17"
83+
Adding certificate to iOS simulator "iPhone 17"...
84+
xcrun simctl keychain 3353AABB-2A54-49FA-B69D-AA4454350523 add-root-cert "/Users/you/https_local/https_local_root_certificate.crt"
85+
✔ certificate added to iOS simulator "iPhone 17"
7986
Check hosts file content...
8087
✔ all ip mappings found in hosts file
8188
```
@@ -97,12 +104,26 @@ Check if certificate is in mac keychain...
97104
✔ certificate found in mac keychain
98105
Check if certificate is in Firefox...
99106
✔ certificate found in Firefox
107+
Check if certificate is in iOS simulator "iPhone 17"...
108+
✔ certificate found in iOS simulator "iPhone 17"
100109
Check hosts file content...
101110
✔ all ip mappings found in hosts file
102111
```
103112

104113
</details>
105114

115+
#### iOS simulator
116+
117+
An iOS simulator has a trust store of its own: a certificate trusted by the mac keychain is still refused by Safari inside the simulator, and a `fetch` towards another origin fails with `TypeError: Load failed` — WebKit only offers the "Visit website" exception for the page itself, not for cross-origin requests.
118+
119+
`init` adds the root certificate to every simulator booted at the time it runs, with full trust: there is nothing to enable in Settings › General › About › Certificate Trust Settings afterwards (that toggle is for certificates installed from a profile). Boot the simulator, then run `init` again; or add it by hand, `booted` standing for every running simulator:
120+
121+
```console
122+
xcrun simctl keychain booted add-root-cert "$HOME/Library/Application Support/https_local/https_local_root_certificate.crt"
123+
```
124+
125+
The certificate stays in the simulator across reboots. It cannot be removed on its own, so `cleanup` leaves it there; `xcrun simctl keychain <udid> reset` wipes the whole simulator keychain.
126+
106127
### generate
107128

108129
```console

packages/tooling/https-local/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jsenv/https-local",
3-
"version": "4.0.2",
3+
"version": "4.0.3",
44
"type": "module",
55
"description": "A programmatic way to generate locally trusted certificates",
66
"repository": {

packages/tooling/https-local/src/https_local_cli.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ if (values.help || positionals.length === 0) {
3737
Usage:
3838
3939
npx @jsenv/https-local init
40-
Install root certificate, trust it and ensure localhost is mapped to 127.0.0.1
40+
Install root certificate, trust it (os, browsers, booted iOS simulators)
41+
and ensure localhost is mapped to 127.0.0.1
4142
4243
npx @jsenv/https-local cleanup
4344
Uninstall root certificate and remove its trust from os and browsers
@@ -52,7 +53,7 @@ Advanced commands:
5253
5354
npx @jsenv/https-local install --trust
5455
Install root certificate on the filesystem
55-
- trust: Try to add root certificate to os and browser trusted stores
56+
- trust: Try to add root certificate to os, browser and booted iOS simulator trusted stores
5657
5758
npx @jsenv/https-local uninstall
5859
Uninstall root certificate from the filesystem
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/*
2+
* iOS simulators have a trust store of their own: a certificate trusted by the
3+
* mac keychain is still refused by Safari (and by fetch) inside a simulator.
4+
* Xcode's `simctl keychain <device> add-root-cert` writes the certificate into
5+
* the trust store of a booted simulator with an empty trust settings array,
6+
* which Apple reads as "trust as root for every policy" — no toggle in
7+
* Settings › General › About › Certificate Trust Settings afterwards.
8+
*
9+
* simctl cannot list or remove trusted roots ("reset" wipes the whole keychain),
10+
* so membership is read from the simulator's trust store database, where
11+
* certificates are keyed by their fingerprint, with the sqlite3 binary
12+
* shipped with macOS.
13+
*/
14+
15+
import { createDetailedMessage, UNICODE } from "@jsenv/humanize";
16+
import { createHash } from "node:crypto";
17+
import { existsSync } from "node:fs";
18+
import { fileURLToPath } from "node:url";
19+
import { commandExists } from "../command.js";
20+
import { exec } from "../exec.js";
21+
import { forge } from "../forge.js";
22+
import {
23+
VERB_ADD_TRUST,
24+
VERB_CHECK_TRUST,
25+
VERB_ENSURE_TRUST,
26+
VERB_REMOVE_TRUST,
27+
} from "../trust_query.js";
28+
29+
const REASON_SIMCTL_NOT_AVAILABLE = "xcrun simctl not available";
30+
const REASON_NO_BOOTED_SIMULATOR = "no booted iOS simulator";
31+
const REASON_NEW_AND_TRY_TO_TRUST_DISABLED =
32+
"certificate is new and tryToTrust is disabled";
33+
const REASON_NOT_IN_SIMULATOR = "certificate not found in iOS simulator";
34+
const REASON_IN_SIMULATOR = "certificate found in iOS simulator";
35+
const REASON_TRUST_STORE_UNREADABLE =
36+
"cannot read the iOS simulator trust store";
37+
const REASON_ADD_TO_SIMULATOR_COMMAND_FAILED =
38+
"command to add certificate in iOS simulator failed";
39+
const REASON_ADD_TO_SIMULATOR_COMMAND_COMPLETED =
40+
"command to add certificate in iOS simulator completed";
41+
const REASON_CANNOT_REMOVE_FROM_SIMULATOR =
42+
"certificate cannot be removed from iOS simulator";
43+
44+
export const executeTrustQueryOnIosSimulator = async ({
45+
logger,
46+
certificateFileUrl,
47+
certificateIsNew,
48+
certificate,
49+
verb,
50+
}) => {
51+
const certificateFilePath = fileURLToPath(certificateFileUrl);
52+
const { simctlAvailable, bootedSimulators } = await listBootedIosSimulators({
53+
logger,
54+
});
55+
if (!simctlAvailable) {
56+
return {
57+
status: "other",
58+
reason: REASON_SIMCTL_NOT_AVAILABLE,
59+
};
60+
}
61+
if (bootedSimulators.length === 0) {
62+
if (verb === VERB_ADD_TRUST || verb === VERB_ENSURE_TRUST) {
63+
logger.info(
64+
`${UNICODE.INFO} no booted iOS simulator, to trust the certificate in one boot it and re-run, or run:
65+
${UNICODE.COMMAND} xcrun simctl keychain booted add-root-cert "${certificateFilePath}"`,
66+
);
67+
} else {
68+
logger.debug(`${UNICODE.INFO} no booted iOS simulator`);
69+
}
70+
return {
71+
status: "other",
72+
reason: REASON_NO_BOOTED_SIMULATOR,
73+
};
74+
}
75+
if (verb === VERB_CHECK_TRUST && certificateIsNew) {
76+
logger.info(`${UNICODE.INFO} You should add certificate to iOS simulator`);
77+
return {
78+
status: "not_trusted",
79+
reason: REASON_NEW_AND_TRY_TO_TRUST_DISABLED,
80+
};
81+
}
82+
83+
const fingerprints = getCertificateFingerprints(certificate);
84+
const results = [];
85+
for (const simulator of bootedSimulators) {
86+
results.push(
87+
await executeTrustQueryOnOneSimulator({
88+
logger,
89+
simulator,
90+
certificateFilePath,
91+
fingerprints,
92+
verb,
93+
}),
94+
);
95+
}
96+
// one entry stands for all booted simulators: the first one not trusted, if any
97+
const notTrustedResult = results.find(
98+
(result) => result.status !== "trusted",
99+
);
100+
return notTrustedResult || results[0];
101+
};
102+
103+
/**
104+
* Booted simulators as reported by simctl.
105+
* simctlAvailable is false when Xcode is not installed: /usr/bin/xcrun then
106+
* exists but has no simctl to run.
107+
*/
108+
export const listBootedIosSimulators = async ({ logger } = {}) => {
109+
const xcrunExists = await commandExists("xcrun");
110+
if (!xcrunExists) {
111+
return { simctlAvailable: false, bootedSimulators: [] };
112+
}
113+
const listCommand = `xcrun simctl list devices booted -j`;
114+
if (logger) {
115+
logger.debug(`${UNICODE.COMMAND} ${listCommand}`);
116+
}
117+
let listCommandOutput;
118+
try {
119+
listCommandOutput = await exec(listCommand);
120+
} catch {
121+
return { simctlAvailable: false, bootedSimulators: [] };
122+
}
123+
const { devices } = JSON.parse(listCommandOutput);
124+
const bootedSimulators = [];
125+
for (const runtime of Object.keys(devices)) {
126+
for (const device of devices[runtime]) {
127+
bootedSimulators.push({
128+
udid: device.udid,
129+
name: device.name,
130+
dataPath: device.dataPath,
131+
});
132+
}
133+
}
134+
return { simctlAvailable: true, bootedSimulators };
135+
};
136+
137+
const executeTrustQueryOnOneSimulator = async ({
138+
logger,
139+
simulator,
140+
certificateFilePath,
141+
fingerprints,
142+
verb,
143+
}) => {
144+
const simulatorLabel = `iOS simulator "${simulator.name}"`;
145+
146+
logger.info(`Check if certificate is in ${simulatorLabel}...`);
147+
const found = await findCertificateInSimulatorTrustStore({
148+
logger,
149+
simulator,
150+
fingerprints,
151+
});
152+
153+
const addCert = async () => {
154+
const addRootCertCommand = `xcrun simctl keychain ${simulator.udid} add-root-cert "${certificateFilePath}"`;
155+
logger.info(`Adding certificate to ${simulatorLabel}...`);
156+
logger.info(`${UNICODE.COMMAND} ${addRootCertCommand}`);
157+
try {
158+
await exec(addRootCertCommand);
159+
logger.info(`${UNICODE.OK} certificate added to ${simulatorLabel}`);
160+
return {
161+
status: "trusted",
162+
reason: REASON_ADD_TO_SIMULATOR_COMMAND_COMPLETED,
163+
};
164+
} catch (e) {
165+
logger.error(
166+
createDetailedMessage(
167+
`${UNICODE.FAILURE} failed to add certificate to ${simulatorLabel}`,
168+
{
169+
"error stack": e.stack,
170+
"certificate file": certificateFilePath,
171+
},
172+
),
173+
);
174+
return {
175+
status: "not_trusted",
176+
reason: REASON_ADD_TO_SIMULATOR_COMMAND_FAILED,
177+
};
178+
}
179+
};
180+
181+
if (found === null) {
182+
logger.info(
183+
`${UNICODE.INFO} cannot check if certificate is in ${simulatorLabel}`,
184+
);
185+
if (verb === VERB_ADD_TRUST || verb === VERB_ENSURE_TRUST) {
186+
// add-root-cert replaces an existing entry, so adding blindly is safe
187+
return addCert();
188+
}
189+
return {
190+
status: "unknown",
191+
reason: REASON_TRUST_STORE_UNREADABLE,
192+
};
193+
}
194+
195+
if (!found) {
196+
logger.info(`${UNICODE.INFO} certificate not found in ${simulatorLabel}`);
197+
if (verb === VERB_CHECK_TRUST || verb === VERB_REMOVE_TRUST) {
198+
return {
199+
status: "not_trusted",
200+
reason: REASON_NOT_IN_SIMULATOR,
201+
};
202+
}
203+
return addCert();
204+
}
205+
206+
logger.info(`${UNICODE.OK} certificate found in ${simulatorLabel}`);
207+
if (verb === VERB_REMOVE_TRUST) {
208+
logger.info(
209+
`${UNICODE.INFO} certificate stays in ${simulatorLabel}: simctl cannot remove a single root certificate, "xcrun simctl keychain ${simulator.udid} reset" wipes the whole simulator keychain`,
210+
);
211+
return {
212+
status: "trusted",
213+
reason: REASON_CANNOT_REMOVE_FROM_SIMULATOR,
214+
};
215+
}
216+
return {
217+
status: "trusted",
218+
reason: REASON_IN_SIMULATOR,
219+
};
220+
};
221+
222+
// Relative to the simulator data directory. The first one is where trustd keeps
223+
// the store on current runtimes (checked on iOS 26), the second is the location
224+
// of older runtimes. The table keys certificates by sha256 on current runtimes,
225+
// by sha1 on older ones.
226+
const TRUST_STORE_RELATIVE_PATHS = [
227+
"private/var/protected/trustd/private/TrustStore.sqlite3",
228+
"Library/Keychains/TrustStore.sqlite3",
229+
];
230+
231+
/**
232+
* true/false when the trust store answers, null when it cannot be read
233+
* (sqlite3 missing, unexpected layout).
234+
* A simulator where no root certificate was ever added has no store file,
235+
* which means "not found".
236+
*/
237+
const findCertificateInSimulatorTrustStore = async ({
238+
logger,
239+
simulator,
240+
fingerprints,
241+
}) => {
242+
const trustStorePath = TRUST_STORE_RELATIVE_PATHS.map(
243+
(relativePath) => `${simulator.dataPath}/${relativePath}`,
244+
).find((path) => existsSync(path));
245+
if (!trustStorePath) {
246+
return false;
247+
}
248+
const sqlite3Exists = await commandExists("sqlite3");
249+
if (!sqlite3Exists) {
250+
logger.debug(`${UNICODE.INFO} sqlite3 not found`);
251+
return null;
252+
}
253+
for (const [column, fingerprint] of [
254+
["sha256", fingerprints.sha256],
255+
["sha1", fingerprints.sha1],
256+
]) {
257+
const selectCommand = `sqlite3 -readonly "${trustStorePath}" "select hex(${column}) from tsettings"`;
258+
logger.debug(`${UNICODE.COMMAND} ${selectCommand}`);
259+
let selectCommandOutput;
260+
try {
261+
selectCommandOutput = await exec(selectCommand);
262+
} catch {
263+
continue;
264+
}
265+
const storedFingerprints = selectCommandOutput
266+
.split("\n")
267+
.map((line) => line.trim().toUpperCase());
268+
return storedFingerprints.includes(fingerprint);
269+
}
270+
logger.debug(
271+
`${UNICODE.INFO} unexpected trust store layout at ${trustStorePath}`,
272+
);
273+
return null;
274+
};
275+
276+
const getCertificateFingerprints = (certificate) => {
277+
const { pki, asn1 } = forge;
278+
const certificateForgeObject = pki.certificateFromPem(certificate);
279+
const der = asn1
280+
.toDer(pki.certificateToAsn1(certificateForgeObject))
281+
.getBytes();
282+
const derBuffer = Buffer.from(der, "binary");
283+
return {
284+
sha256: createHash("sha256").update(derBuffer).digest("hex").toUpperCase(),
285+
sha1: createHash("sha1").update(derBuffer).digest("hex").toUpperCase(),
286+
};
287+
};

0 commit comments

Comments
 (0)