|
| 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