/ utils / caCerts.ts
caCerts.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { logForDebugging } from './debug.js'
  3  import { hasNodeOption } from './envUtils.js'
  4  import { getFsImplementation } from './fsOperations.js'
  5  
  6  /**
  7   * Load CA certificates for TLS connections.
  8   *
  9   * Since setting `ca` on an HTTPS agent replaces the default certificate store,
 10   * we must always include base CAs (either system or bundled Mozilla) when returning.
 11   *
 12   * Returns undefined when no custom CA configuration is needed, allowing the
 13   * runtime's default certificate handling to apply.
 14   *
 15   * Behavior:
 16   * - Neither NODE_EXTRA_CA_CERTS nor --use-system-ca/--use-openssl-ca set: undefined (runtime defaults)
 17   * - NODE_EXTRA_CA_CERTS only: bundled Mozilla CAs + extra cert file contents
 18   * - --use-system-ca or --use-openssl-ca only: system CAs
 19   * - --use-system-ca + NODE_EXTRA_CA_CERTS: system CAs + extra cert file contents
 20   *
 21   * Memoized for performance. Call clearCACertsCache() to invalidate after
 22   * environment variable changes (e.g., after trust dialog applies settings.json).
 23   *
 24   * Reads ONLY `process.env.NODE_EXTRA_CA_CERTS`. `caCertsConfig.ts` populates
 25   * that env var from settings.json at CLI init; this module stays config-free
 26   * so `proxy.ts`/`mtls.ts` don't transitively pull in the command registry.
 27   */
 28  export const getCACertificates = memoize((): string[] | undefined => {
 29    const useSystemCA =
 30      hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca')
 31  
 32    const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS
 33  
 34    logForDebugging(
 35      `CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`,
 36    )
 37  
 38    // If neither is set, return undefined (use runtime defaults, no override)
 39    if (!useSystemCA && !extraCertsPath) {
 40      return undefined
 41    }
 42  
 43    // Deferred load: Bun's node:tls module eagerly materializes ~150 Mozilla
 44    // root certificates (~750KB heap) on import, even if tls.rootCertificates
 45    // is never accessed. Most users hit the early return above, so we only
 46    // pay this cost when custom CA handling is actually needed.
 47    /* eslint-disable @typescript-eslint/no-require-imports */
 48    const tls = require('tls') as typeof import('tls')
 49    /* eslint-enable @typescript-eslint/no-require-imports */
 50  
 51    const certs: string[] = []
 52  
 53    if (useSystemCA) {
 54      // Load system CA store (Bun API)
 55      const getCACerts = (
 56        tls as typeof tls & { getCACertificates?: (type: string) => string[] }
 57      ).getCACertificates
 58      const systemCAs = getCACerts?.('system')
 59      if (systemCAs && systemCAs.length > 0) {
 60        certs.push(...systemCAs)
 61        logForDebugging(
 62          `CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`,
 63        )
 64      } else if (!getCACerts && !extraCertsPath) {
 65        // Under Node.js where getCACertificates doesn't exist and no extra certs,
 66        // return undefined to let Node.js handle --use-system-ca natively.
 67        logForDebugging(
 68          'CA certs: --use-system-ca set but system CA API unavailable, deferring to runtime',
 69        )
 70        return undefined
 71      } else {
 72        // System CA API returned empty or unavailable; fall back to bundled root certs
 73        certs.push(...tls.rootCertificates)
 74        logForDebugging(
 75          `CA certs: Loaded ${certs.length} bundled root certificates as base (--use-system-ca fallback)`,
 76        )
 77      }
 78    } else {
 79      // Must include bundled Mozilla CAs as base since ca replaces defaults
 80      certs.push(...tls.rootCertificates)
 81      logForDebugging(
 82        `CA certs: Loaded ${certs.length} bundled root certificates as base`,
 83      )
 84    }
 85  
 86    // Append extra certs from file
 87    if (extraCertsPath) {
 88      try {
 89        const extraCert = getFsImplementation().readFileSync(extraCertsPath, {
 90          encoding: 'utf8',
 91        })
 92        certs.push(extraCert)
 93        logForDebugging(
 94          `CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`,
 95        )
 96      } catch (error) {
 97        logForDebugging(
 98          `CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`,
 99          { level: 'error' },
100        )
101      }
102    }
103  
104    return certs.length > 0 ? certs : undefined
105  })
106  
107  /**
108   * Clear the CA certificates cache.
109   * Call this when environment variables that affect CA certs may have changed
110   * (e.g., NODE_EXTRA_CA_CERTS, NODE_OPTIONS).
111   */
112  export function clearCACertsCache(): void {
113    getCACertificates.cache.clear?.()
114    logForDebugging('Cleared CA certificates cache')
115  }