@jsenv/https-local 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@ Generate locally trusted HTTPS certificates for local development.
5
5
  🔒 Certificates trusted by your operating system and browsers
6
6
  🌐 Perfect for local HTTPS development
7
7
  🖥️ Works on macOS, Linux, and Windows
8
+ 📱 Trusted by iOS simulators too
8
9
  ⚡ Simple CLI and JavaScript API
9
10
 
10
11
  ## Table of Contents
@@ -14,6 +15,7 @@ Generate locally trusted HTTPS certificates for local development.
14
15
  - [Quick Start](#quick-start)
15
16
  - [CLI](#cli)
16
17
  - [init](#init)
18
+ - [iOS simulator](#ios-simulator)
17
19
  - [generate](#generate)
18
20
  - [cleanup](#cleanup)
19
21
  - [Certificate Expiration](#certificate-expiration)
@@ -59,7 +61,7 @@ const server = createServer(
59
61
  npx @jsenv/https-local init
60
62
  ```
61
63
 
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.
63
65
 
64
66
  <details>
65
67
  <summary>First execution (macOS)</summary>
@@ -76,6 +78,11 @@ Password:
76
78
  ✔ certificate added to mac keychain
77
79
  Adding certificate to firefox...
78
80
  ✔ 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"
79
86
  Check hosts file content...
80
87
  ✔ all ip mappings found in hosts file
81
88
  ```
@@ -97,12 +104,26 @@ Check if certificate is in mac keychain...
97
104
  ✔ certificate found in mac keychain
98
105
  Check if certificate is in Firefox...
99
106
  ✔ certificate found in Firefox
107
+ Check if certificate is in iOS simulator "iPhone 17"...
108
+ ✔ certificate found in iOS simulator "iPhone 17"
100
109
  Check hosts file content...
101
110
  ✔ all ip mappings found in hosts file
102
111
  ```
103
112
 
104
113
  </details>
105
114
 
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
+
106
127
  ### generate
107
128
 
108
129
  ```console
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/https-local",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "type": "module",
5
5
  "description": "A programmatic way to generate locally trusted certificates",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "test:start-node-server": "node ./scripts/certificate/start_node_server.mjs"
38
38
  },
39
39
  "dependencies": {
40
- "@jsenv/filesystem": "4.15.18",
40
+ "@jsenv/filesystem": "4.15.19",
41
41
  "@jsenv/humanize": "1.7.8",
42
42
  "@jsenv/urls": "2.9.10",
43
43
  "command-exists": "1.2.9",
@@ -37,7 +37,8 @@ if (values.help || positionals.length === 0) {
37
37
  Usage:
38
38
 
39
39
  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
41
42
 
42
43
  npx @jsenv/https-local cleanup
43
44
  Uninstall root certificate and remove its trust from os and browsers
@@ -52,7 +53,7 @@ Advanced commands:
52
53
 
53
54
  npx @jsenv/https-local install --trust
54
55
  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
56
57
 
57
58
  npx @jsenv/https-local uninstall
58
59
  Uninstall root certificate from the filesystem
@@ -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
+ };
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { executeTrustQueryOnChrome } from "./chrome_mac.js";
8
8
  import { executeTrustQueryOnFirefox } from "./firefox_mac.js";
9
+ import { executeTrustQueryOnIosSimulator } from "./ios_simulator.js";
9
10
  import { executeTrustQueryOnMacKeychain } from "./mac_keychain.js";
10
11
  import { executeTrustQueryOnSafari } from "./safari.js";
11
12
 
@@ -48,10 +49,20 @@ export const executeTrustQuery = async ({
48
49
  macTrustInfo,
49
50
  });
50
51
 
52
+ // simulators have their own trust store, the mac keychain does not reach them
53
+ const iosSimulatorTrustInfo = await executeTrustQueryOnIosSimulator({
54
+ logger,
55
+ certificateFileUrl,
56
+ certificateIsNew,
57
+ certificate,
58
+ verb,
59
+ });
60
+
51
61
  return {
52
62
  mac: macTrustInfo,
53
63
  chrome: chromeTrustInfo,
54
64
  firefox: firefoxTrustInfo,
55
65
  safari: safariTrustInfo,
66
+ iosSimulator: iosSimulatorTrustInfo,
56
67
  };
57
68
  };