feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,144 @@
import {
unlinkSync as rm,
readFileSync as readFile,
writeFileSync as writeFile
} from 'fs';
import createDebug from 'debug';
import {
domainsDir,
rootCADir,
ensureConfigDirs,
getLegacyConfigDir,
rootCAKeyPath,
rootCACertPath,
caSelfSignConfig,
opensslSerialFilePath,
opensslDatabaseFilePath,
caVersionFile
} from './constants';
import currentPlatform from './platforms';
import { openssl, mktmp } from './utils';
import { generateKey } from './certificates';
import { Options } from './index';
const debug = createDebug('devcert:certificate-authority');
/**
* Install the once-per-machine trusted root CA. We'll use this CA to sign
* per-app certs.
*/
export default async function installCertificateAuthority(options: Options = {}): Promise<void> {
debug(`Uninstalling existing certificates, which will be void once any existing CA is gone`);
uninstall();
ensureConfigDirs();
debug(`Making a temp working directory for files to copied in`);
let rootKeyPath = mktmp();
debug(`Generating the OpenSSL configuration needed to setup the certificate authority`);
seedConfigFiles();
debug(`Generating a private key`);
generateKey(rootKeyPath);
debug(`Generating a CA certificate`);
openssl(['req', '-new', '-x509', '-config', caSelfSignConfig, '-key', rootKeyPath, '-out', rootCACertPath, '-days', '825']);
debug('Saving certificate authority credentials');
await saveCertificateAuthorityCredentials(rootKeyPath);
debug(`Adding the root certificate authority to trust stores`);
await currentPlatform.addToTrustStores(rootCACertPath, options);
}
/**
* Initializes the files OpenSSL needs to sign certificates as a certificate
* authority, as well as our CA setup version
*/
function seedConfigFiles() {
// This is v2 of the devcert certificate authority setup
writeFile(caVersionFile, '2');
// OpenSSL CA files
writeFile(opensslDatabaseFilePath, '');
writeFile(opensslSerialFilePath, '01');
}
export async function withCertificateAuthorityCredentials(cb: ({ caKeyPath, caCertPath }: { caKeyPath: string, caCertPath: string }) => Promise<void> | void) {
debug(`Retrieving devcert's certificate authority credentials`);
let tmpCAKeyPath = mktmp();
let caKey = await currentPlatform.readProtectedFile(rootCAKeyPath);
writeFile(tmpCAKeyPath, caKey);
await cb({ caKeyPath: tmpCAKeyPath, caCertPath: rootCACertPath });
rm(tmpCAKeyPath);
}
async function saveCertificateAuthorityCredentials(keypath: string) {
debug(`Saving devcert's certificate authority credentials`);
let key = readFile(keypath, 'utf-8');
await currentPlatform.writeProtectedFile(rootCAKeyPath, key);
}
function certErrors(): string {
try {
openssl(['x509', '-in', rootCACertPath, '-noout']);
return '';
} catch (e) {
return e.toString();
}
}
// This function helps to migrate from v1.0.x to >= v1.1.0.
/**
* Smoothly migrate the certificate storage from v1.0.x to >= v1.1.0.
* In v1.1.0 there are new options for retrieving the CA cert directly,
* to help third-party Node apps trust the root CA.
*
* If a v1.0.x cert already exists, then devcert has written it with
* platform.writeProtectedFile(), so an unprivileged readFile cannot access it.
* Pre-detect and remedy this; it should only happen once per installation.
*/
export async function ensureCACertReadable(options: Options = {}): Promise<void> {
if (!certErrors()) {
return;
}
/**
* on windows, writeProtectedFile left the cert encrypted on *nix, the cert
* has no read permissions either way, openssl will fail and that means we
* have to fix it
*/
try {
const caFileContents = await currentPlatform.readProtectedFile(rootCACertPath);
await currentPlatform.deleteProtectedFiles(rootCACertPath);
writeFile(rootCACertPath, caFileContents);
} catch (e) {
return await installCertificateAuthority(options);
}
// double check that we have a live one
const remainingErrors = certErrors();
if (remainingErrors) {
return await installCertificateAuthority(options);
}
}
/**
* Remove as much of the devcert files and state as we can. This is necessary
* when generating a new root certificate, and should be available to API
* consumers as well.
*
* Not all of it will be removable. If certutil is not installed, we'll leave
* Firefox alone. We try to remove files with maximum permissions, and if that
* fails, we'll silently fail.
*
* It's also possible that the command to untrust will not work, and we'll
* silently fail that as well; with no existing certificates anymore, the
* security exposure there is minimal.
*/
export async function uninstall(): Promise<void> {
await currentPlatform.removeFromTrustStores(rootCACertPath);
await currentPlatform.deleteProtectedFiles(domainsDir);
await currentPlatform.deleteProtectedFiles(rootCADir);
await currentPlatform.deleteProtectedFiles(getLegacyConfigDir());
}

View File

@@ -0,0 +1,45 @@
// import path from 'path';
import createDebug from 'debug';
import fs from 'fs';
import { pathForDomain, withDomainSigningRequestConfig, withDomainCertificateConfig } from './constants';
import { openssl } from './utils';
import { withCertificateAuthorityCredentials } from './certificate-authority';
const debug = createDebug('devcert:certificates');
/**
* Generate a domain certificate signed by the devcert root CA. Domain
* certificates are cached in their own directories under
* CONFIG_ROOT/domains/<domain>, and reused on subsequent requests. Because the
* individual domain certificates are signed by the devcert root CA (which was
* added to the OS/browser trust stores), they are trusted.
*/
export default async function generateDomainCertificate(domain: string): Promise<void> {
await fs.promises.mkdir(pathForDomain(domain), { recursive: true });
debug(`Generating private key for ${ domain }`);
let domainKeyPath = pathForDomain(domain, 'private-key.key');
generateKey(domainKeyPath);
debug(`Generating certificate signing request for ${ domain }`);
let csrFile = pathForDomain(domain, `certificate-signing-request.csr`);
withDomainSigningRequestConfig(domain, (configpath) => {
openssl(['req', '-new', '-config', configpath, '-key', domainKeyPath, '-out', csrFile]);
});
debug(`Generating certificate for ${ domain } from signing request and signing with root CA`);
let domainCertPath = pathForDomain(domain, `certificate.crt`);
await withCertificateAuthorityCredentials(({ caKeyPath, caCertPath }) => {
withDomainCertificateConfig(domain, (domainCertConfigPath) => {
openssl(['ca', '-config', domainCertConfigPath, '-in', csrFile, '-out', domainCertPath, '-keyfile', caKeyPath, '-cert', caCertPath, '-days', '825', '-batch'])
});
});
}
// Generate a cryptographic key, used to sign certificates or certificate signing requests.
export function generateKey(filename: string): void {
debug(`generateKey: ${ filename }`);
openssl(['genrsa', '-out', filename, '2048']);
fs.chmodSync(filename, 400);
}

View File

@@ -0,0 +1,94 @@
import path from 'path';
import fs from 'fs';
import { mktmp } from './utils';
function applicationConfigPath(name: string): string {
switch (process.platform) {
case 'darwin':
return path.join(process.env.HOME, 'Library', 'Application Support', name);
case 'win32':
return process.env.LOCALAPPDATA
? path.join(process.env.LOCALAPPDATA, name)
: path.join(process.env.USERPROFILE, 'Local Settings', 'Application Data', name);
case 'linux':
default:
return process.env.XDG_CONFIG_HOME
? path.join(process.env.XDG_CONFIG_HOME, name)
: path.join(process.env.HOME, '.config', name);
}
}
export const VALID_IP = /(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}/;
export const VALID_DOMAIN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i;
// Platform shortcuts
export const isMac = process.platform === 'darwin';
export const isLinux = process.platform === 'linux';
export const isWindows = process.platform === 'win32';
// Common paths
export const configDir = applicationConfigPath('devcert');
export const configPath: (...pathSegments: string[]) => string = path.join.bind(path, configDir);
export const domainsDir = configPath('domains');
export const pathForDomain: (domain: string, ...pathSegments: string[]) => string = path.join.bind(path, domainsDir)
export const caVersionFile = configPath('devcert-ca-version');
export const opensslSerialFilePath = configPath('certificate-authority', 'serial');
export const opensslDatabaseFilePath = configPath('certificate-authority', 'index.txt');
export const caSelfSignConfig = path.join(__dirname, '../openssl-configurations/certificate-authority-self-signing.conf');
function eolAuto(str: string): string {
return str.replace(/\r?\n|\r/g, isWindows ? '\r\n' : '\n');
}
export function withDomainSigningRequestConfig(domain: string, cb: (filepath: string) => void) {
let tmpFile = mktmp();
let source = fs.readFileSync(path.join(__dirname, '../openssl-configurations/domain-certificate-signing-requests.conf'), 'utf-8');
let result = source.replace(/%DOMAIN%/g, domain);
fs.writeFileSync(tmpFile, eolAuto(result));
cb(tmpFile);
fs.rmSync(tmpFile);
}
export function withDomainCertificateConfig(domain: string, cb: (filepath: string) => void) {
let tmpFile = mktmp();
let source = fs.readFileSync(path.join(__dirname, '../openssl-configurations/domain-certificates.conf'), 'utf-8');
let result = source
.replace(/%DOMAIN%/g, domain)
.replace(/%SERIALFILE%/g, opensslSerialFilePath.replace(/\\/g, '\\\\'))
.replace(/%DATABASEFILE%/g, opensslDatabaseFilePath.replace(/\\/g, '\\\\'))
.replace(/%DOMAINDIR%/g, pathForDomain(domain).replace(/\\/g, '\\\\'))
fs.writeFileSync(tmpFile, eolAuto(result));
cb(tmpFile);
fs.rmSync(tmpFile);
}
// confTemplate = confTemplate.replace(/DATABASE_PATH/, configPath('index.txt').replace(/\\/g, '\\\\'));
// confTemplate = confTemplate.replace(/SERIAL_PATH/, configPath('serial').replace(/\\/g, '\\\\'));
// confTemplate = eolAuto(confTemplate);
export const rootCADir = configPath('certificate-authority');
export const rootCAKeyPath = configPath('certificate-authority', 'private-key.key');
export const rootCACertPath = configPath('certificate-authority', 'certificate.cert');
// Exposed for uninstallation purposes.
export function getLegacyConfigDir(): string {
if (isWindows && process.env.LOCALAPPDATA) {
return path.join(process.env.LOCALAPPDATA, 'devcert', 'config');
} else {
let uid = process.getuid && process.getuid();
let userHome = (isLinux && uid === 0) ? path.resolve('/usr/local/share') : require('os').homedir();
return path.join(userHome, '.config', 'devcert');
}
}
export function ensureConfigDirs() {
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(domainsDir, { recursive: true });
fs.mkdirSync(rootCADir, { recursive: true });
}
ensureConfigDirs();

View File

@@ -0,0 +1,131 @@
import { rmSync as rm, readFileSync as readFile, readdirSync as readdir, existsSync as exists } from 'fs';
import createDebug from 'debug';
import {
isMac,
isLinux,
isWindows,
pathForDomain,
domainsDir,
rootCAKeyPath,
rootCACertPath,
VALID_DOMAIN,
VALID_IP
} from './constants';
import currentPlatform from './platforms';
import { commandExists } from './utils';
import installCertificateAuthority, { ensureCACertReadable, uninstall } from './certificate-authority';
import generateDomainCertificate from './certificates';
import UI, { UserInterface } from './user-interface';
export { uninstall };
const debug = createDebug('devcert');
export interface Options /* extends Partial<ICaBufferOpts & ICaPathOpts> */{
/** Return the CA certificate data? */
getCaBuffer?: boolean;
/** Return the path to the CA certificate? */
getCaPath?: boolean;
/** If `certutil` is not installed already (for updating nss databases; e.g. firefox), do not attempt to install it */
skipCertutilInstall?: boolean,
/** Do not update your systems host file with the domain name of the certificate */
skipHostsFile?: boolean,
/** User interface hooks */
ui?: UserInterface
}
interface ICaBuffer {
ca: Buffer;
}
interface ICaPath {
caPath: string;
}
interface IDomainData {
key: Buffer;
cert: Buffer;
}
type IReturnCa<O extends Options> = O['getCaBuffer'] extends true ? ICaBuffer : false;
type IReturnCaPath<O extends Options> = O['getCaPath'] extends true ? ICaPath : false;
type IReturnData<O extends Options = {}> = (IDomainData) & (IReturnCa<O>) & (IReturnCaPath<O>);
/**
* Request an SSL certificate for the given app name signed by the devcert root
* certificate authority. If devcert has previously generated a certificate for
* that app name on this machine, it will reuse that certificate.
*
* If this is the first time devcert is being run on this machine, it will
* generate and attempt to install a root certificate authority.
*
* Returns a promise that resolves with { key, cert }, where `key` and `cert`
* are Buffers with the contents of the certificate private key and certificate
* file, respectively
*
* If `options.getCaBuffer` is true, return value will include the ca certificate data
* as { ca: Buffer }
*
* If `options.getCaPath` is true, return value will include the ca certificate path
* as { caPath: string }
*/
export async function certificateFor<O extends Options>(domain: string, options: O = {} as O): Promise<IReturnData<O>> {
if (VALID_IP.test(domain)) {
throw new Error('IP addresses are not supported currently');
}
if (!VALID_DOMAIN.test(domain)) {
throw new Error(`"${domain}" is not a valid domain name.`);
}
debug(`Certificate requested for ${ domain }. Skipping certutil install: ${ Boolean(options.skipCertutilInstall) }. Skipping hosts file: ${ Boolean(options.skipHostsFile) }`);
if (options.ui) {
Object.assign(UI, options.ui);
}
if (!isMac && !isLinux && !isWindows) {
throw new Error(`Platform not supported: "${ process.platform }"`);
}
if (!commandExists('openssl')) {
throw new Error('OpenSSL not found: OpenSSL is required to generate SSL certificates - make sure it is installed and available in your PATH');
}
let domainKeyPath = pathForDomain(domain, `private-key.key`);
let domainCertPath = pathForDomain(domain, `certificate.crt`);
if (!exists(rootCAKeyPath)) {
debug('Root CA is not installed yet, so it must be our first run. Installing root CA ...');
await installCertificateAuthority(options);
} else if (options.getCaBuffer || options.getCaPath) {
debug('Root CA is not readable, but it probably is because an earlier version of devcert locked it. Trying to fix...');
await ensureCACertReadable(options);
}
if (!exists(pathForDomain(domain, `certificate.crt`))) {
debug(`Can't find certificate file for ${ domain }, so it must be the first request for ${ domain }. Generating and caching ...`);
await generateDomainCertificate(domain);
}
if (!options.skipHostsFile) {
await currentPlatform.addDomainToHostFileIfMissing(domain);
}
debug(`Returning domain certificate`);
const ret = {
key: readFile(domainKeyPath),
cert: readFile(domainCertPath)
} as IReturnData<O>;
if (options.getCaBuffer) (ret as ICaBuffer).ca = readFile(rootCACertPath);
if (options.getCaPath) (ret as ICaPath).caPath = rootCACertPath;
return ret;
}
export function hasCertificateFor(domain: string) {
return exists(pathForDomain(domain, `certificate.crt`));
}
export function configuredDomains() {
return readdir(domainsDir);
}
export function removeDomain(domain: string) {
return rm(pathForDomain(domain), { force: true, recursive: true });
}

View File

@@ -0,0 +1,135 @@
import path from 'path';
import { writeFileSync as writeFile, existsSync as exists, readFileSync as read } from 'fs';
import createDebug from 'debug';
import { run, sudoAppend, commandExists } from '../utils';
import { Options } from '../index';
import { addCertificateToNSSCertDB, assertNotTouchingFiles, openCertificateInFirefox, closeFirefox, removeCertificateFromNSSCertDB } from './shared';
import { Platform } from '.';
const debug = createDebug('devcert:platforms:macos');
const getCertUtilPath = () => path.join(run('brew', ['--prefix', 'nss']).toString().trim(), 'bin', 'certutil');
export default class MacOSPlatform implements Platform {
private FIREFOX_BUNDLE_PATH = '/Applications/Firefox.app';
private FIREFOX_BIN_PATH = path.join(this.FIREFOX_BUNDLE_PATH, 'Contents/MacOS/firefox');
private FIREFOX_NSS_DIR = path.join(process.env.HOME, 'Library/Application Support/Firefox/Profiles/*');
private HOST_FILE_PATH = '/etc/hosts';
/**
* macOS is pretty simple - just add the certificate to the system keychain,
* and most applications will delegate to that for determining trusted
* certificates. Firefox, of course, does it's own thing. We can try to
* automatically install the cert with Firefox if we can use certutil via the
* `nss` Homebrew package, otherwise we go manual with user-facing prompts.
*/
async addToTrustStores(certificatePath: string, options: Options = {}): Promise<void> {
// Chrome, Safari, system utils
debug('Adding devcert root CA to macOS system keychain');
run('sudo', [
'security',
'add-trusted-cert',
'-d',
'-r',
'trustRoot',
'-k',
'/Library/Keychains/System.keychain',
'-p',
'ssl',
'-p',
'basic',
certificatePath
]);
if (this.isFirefoxInstalled()) {
// Try to use certutil to install the cert automatically
debug('Firefox install detected. Adding devcert root CA to Firefox trust store');
if (!this.isNSSInstalled()) {
if (!options.skipCertutilInstall) {
if (commandExists('brew')) {
debug(`certutil is not already installed, but Homebrew is detected. Trying to install certutil via Homebrew...`);
try {
run('brew', ['install', 'nss'], { stdio: 'ignore' });
} catch (e) {
debug(`brew install nss failed`);
}
} else {
debug(`Homebrew didn't work, so we can't try to install certutil. Falling back to manual certificate install`);
return await openCertificateInFirefox(this.FIREFOX_BIN_PATH, certificatePath);
}
} else {
debug(`certutil is not already installed, and skipCertutilInstall is true, so we have to fall back to a manual install`)
return await openCertificateInFirefox(this.FIREFOX_BIN_PATH, certificatePath);
}
}
await closeFirefox();
await addCertificateToNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, getCertUtilPath());
} else {
debug('Firefox does not appear to be installed, skipping Firefox-specific steps...');
}
}
async removeFromTrustStores(certificatePath: string) {
debug('Removing devcert root CA from macOS system keychain');
try {
run('sudo', [
'security',
'remove-trusted-cert',
'-d',
certificatePath
], {
stdio: 'ignore'
});
} catch(e) {
debug(`failed to remove ${ certificatePath } from macOS cert store, continuing. ${ e.toString() }`);
}
if (this.isFirefoxInstalled() && this.isNSSInstalled()) {
debug('Firefox install and certutil install detected. Trying to remove root CA from Firefox NSS databases');
await removeCertificateFromNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, getCertUtilPath());
}
}
async addDomainToHostFileIfMissing(domain: string) {
const trimDomain = domain.trim().replace(/[\s;]/g,'')
let hostsFileContents = read(this.HOST_FILE_PATH, 'utf8');
if (!hostsFileContents.includes(trimDomain)) {
sudoAppend(this.HOST_FILE_PATH, `127.0.0.1 ${trimDomain}\n`);
}
}
async deleteProtectedFiles(filepath: string) {
assertNotTouchingFiles(filepath, 'delete');
run('sudo', ['rm', '-rf', filepath]);
}
async readProtectedFile(filepath: string) {
assertNotTouchingFiles(filepath, 'read');
return (await run('sudo', ['cat', filepath])).toString().trim();
}
async writeProtectedFile(filepath: string, contents: string) {
assertNotTouchingFiles(filepath, 'write');
if (exists(filepath)) {
await run('sudo', ['rm', filepath]);
}
writeFile(filepath, contents);
await run('sudo', ['chown', '0', filepath]);
await run('sudo', ['chmod', '600', filepath]);
}
private isFirefoxInstalled() {
return exists(this.FIREFOX_BUNDLE_PATH);
}
private isNSSInstalled() {
try {
return run('brew', ['list', '-1']).toString().includes('\nnss\n');
} catch (e) {
return false;
}
}
};

View File

@@ -0,0 +1,14 @@
import { Options } from '../index';
export interface Platform {
addToTrustStores(certificatePath: string, options?: Options): Promise<void>;
removeFromTrustStores(certificatePath: string): Promise<void>;
addDomainToHostFileIfMissing(domain: string): Promise<void>;
deleteProtectedFiles(filepath: string): Promise<void>;
readProtectedFile(filepath: string): Promise<string>;
writeProtectedFile(filepath: string, contents: string): Promise<void>;
}
const PlatformClass = require(`./${ process.platform }`).default;
export default new PlatformClass() as Platform;

View File

@@ -0,0 +1,123 @@
import path from 'path';
import { existsSync as exists, readFileSync as read, writeFileSync as writeFile } from 'fs';
import createDebug from 'debug';
import { addCertificateToNSSCertDB, assertNotTouchingFiles, openCertificateInFirefox, closeFirefox, removeCertificateFromNSSCertDB } from './shared';
import { run, sudoAppend, commandExists } from '../utils';
import { Options } from '../index';
import UI from '../user-interface';
import { Platform } from '.';
const debug = createDebug('devcert:platforms:linux');
export default class LinuxPlatform implements Platform {
private FIREFOX_NSS_DIR = path.join(process.env.HOME, '.mozilla/firefox/*');
private CHROME_NSS_DIR = path.join(process.env.HOME, '.pki/nssdb');
private FIREFOX_BIN_PATH = '/usr/bin/firefox';
private CHROME_BIN_PATH = '/usr/bin/google-chrome';
private HOST_FILE_PATH = '/etc/hosts';
/**
* Linux is surprisingly difficult. There seems to be multiple system-wide
* repositories for certs, so we copy ours to each. However, Firefox does it's
* usual separate trust store. Plus Chrome relies on the NSS tooling (like
* Firefox), but uses the user's NSS database, unlike Firefox (which uses a
* separate Mozilla one). And since Chrome doesn't prompt the user with a GUI
* flow when opening certs, if we can't use certutil to install our certificate
* into the user's NSS database, we're out of luck.
*/
async addToTrustStores(certificatePath: string, options: Options = {}): Promise<void> {
debug('Adding devcert root CA to Linux system-wide trust stores');
// run(`sudo cp ${ certificatePath } /etc/ssl/certs/devcert.crt`);
run('sudo', ['cp', certificatePath, '/usr/local/share/ca-certificates/devcert.crt']);
// run(`sudo bash -c "cat ${ certificatePath } >> /etc/ssl/certs/ca-certificates.crt"`);
run('sudo', ['update-ca-certificates']);
if (this.isFirefoxInstalled()) {
// Firefox
debug('Firefox install detected: adding devcert root CA to Firefox-specific trust stores ...');
if (!commandExists('certutil')) {
if (options.skipCertutilInstall) {
debug('NSS tooling is not already installed, and `skipCertutil` is true, so falling back to manual certificate install for Firefox');
openCertificateInFirefox(this.FIREFOX_BIN_PATH, certificatePath);
} else {
debug('NSS tooling is not already installed. Trying to install NSS tooling now with `apt install`');
run('sudo', ['apt', 'install', 'libnss3-tools']);
debug('Installing certificate into Firefox trust stores using NSS tooling');
await closeFirefox();
await addCertificateToNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, 'certutil');
}
}
} else {
debug('Firefox does not appear to be installed, skipping Firefox-specific steps...');
}
if (this.isChromeInstalled()) {
debug('Chrome install detected: adding devcert root CA to Chrome trust store ...');
if (!commandExists('certutil')) {
UI.warnChromeOnLinuxWithoutCertutil();
} else {
await closeFirefox();
await addCertificateToNSSCertDB(this.CHROME_NSS_DIR, certificatePath, 'certutil');
}
} else {
debug('Chrome does not appear to be installed, skipping Chrome-specific steps...');
}
}
async removeFromTrustStores(certificatePath: string) {
try {
run('sudo', ['rm', '/usr/local/share/ca-certificates/devcert.crt']);
run('sudo', ['update-ca-certificates']);
} catch (e) {
debug(`failed to remove ${ certificatePath } from /usr/local/share/ca-certificates, continuing. ${ e.toString() }`);
}
if (commandExists('certutil')) {
if (this.isFirefoxInstalled()) {
await removeCertificateFromNSSCertDB(this.FIREFOX_NSS_DIR, certificatePath, 'certutil');
}
if (this.isChromeInstalled()) {
await removeCertificateFromNSSCertDB(this.CHROME_NSS_DIR, certificatePath, 'certutil');
}
}
}
async addDomainToHostFileIfMissing(domain: string) {
const trimDomain = domain.trim().replace(/[\s;]/g,'')
let hostsFileContents = read(this.HOST_FILE_PATH, 'utf8');
if (!hostsFileContents.includes(trimDomain)) {
sudoAppend(this.HOST_FILE_PATH, `127.0.0.1 ${trimDomain}\n`);
}
}
async deleteProtectedFiles(filepath: string) {
assertNotTouchingFiles(filepath, 'delete');
run('sudo', ['rm', '-rf', filepath]);
}
async readProtectedFile(filepath: string) {
assertNotTouchingFiles(filepath, 'read');
return (await run('sudo', ['cat', filepath])).toString().trim();
}
async writeProtectedFile(filepath: string, contents: string) {
assertNotTouchingFiles(filepath, 'write');
if (exists(filepath)) {
await run('sudo', ['rm', filepath]);
}
writeFile(filepath, contents);
await run('sudo', ['chown', '0', filepath]);
await run('sudo', ['chmod', '600', filepath]);
}
private isFirefoxInstalled() {
return exists(this.FIREFOX_BIN_PATH);
}
private isChromeInstalled() {
return exists(this.CHROME_BIN_PATH);
}
}

View File

@@ -0,0 +1,162 @@
import path from 'path';
import createDebug from 'debug';
import assert from 'assert';
import net from 'net';
import http from 'http';
import fs from 'fs';
import { run } from '../utils';
import { isMac, isLinux , configDir, getLegacyConfigDir } from '../constants';
import UI from '../user-interface';
import { execSync as exec } from 'child_process';
const debug = createDebug('devcert:platforms:shared');
async function* iterateNSSCertDBPaths(nssDirGlob: string): AsyncGenerator<string> {
const globIdx = nssDirGlob.indexOf('*');
if (globIdx === -1) {
try {
const stat = fs.statSync(nssDirGlob);
if (stat.isDirectory()) {
yield nssDirGlob;
}
} catch (_error) {
// no matching directory found
}
} else if (globIdx === nssDirGlob.length - 1) {
const targetDir = path.dirname(nssDirGlob);
for (const entry of await fs.promises.readdir(targetDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
yield path.join(targetDir, entry.name);
}
}
} else {
throw new Error('Internal: Invalid `nssDirGlob` specified');
}
}
async function* iterateNSSCertDBs(nssDirGlob: string): AsyncGenerator<{ dir: string; version: 'legacy' | 'modern' }> {
for await (const dir of iterateNSSCertDBPaths(nssDirGlob)) {
debug(`checking to see if ${dir} is a valid NSS database directory`);
if (fs.existsSync(path.join(dir, 'cert8.db'))) {
debug(`Found legacy NSS database in ${dir}, emitting...`);
yield { dir, version: 'legacy' };
}
if (fs.existsSync(path.join(dir, 'cert9.db'))) {
debug(`Found modern NSS database in ${dir}, running callback...`)
yield { dir, version: 'modern' };
}
}
}
/**
* Given a directory or glob pattern of directories, attempt to install the
* CA certificate to each directory containing an NSS database.
*/
export async function addCertificateToNSSCertDB(nssDirGlob: string, certPath: string, certutilPath: string): Promise<void> {
debug(`trying to install certificate into NSS databases in ${ nssDirGlob }`);
for await (const { dir, version } of iterateNSSCertDBs(nssDirGlob)) {
const dirArg = version === 'modern' ? `sql:${ dir }` : dir;
run(certutilPath, ['-A', '-d', dirArg, '-t', 'C,,', '-i', certPath, '-n', 'devcert']);
}
debug(`finished scanning & installing certificate in NSS databases in ${ nssDirGlob }`);
}
export async function removeCertificateFromNSSCertDB(nssDirGlob: string, certPath: string, certutilPath: string): Promise<void> {
debug(`trying to remove certificates from NSS databases in ${ nssDirGlob }`);
for await (const { dir, version } of iterateNSSCertDBs(nssDirGlob)) {
const dirArg = version === 'modern' ? `sql:${ dir }` : dir;
try {
run(certutilPath, ['-A', '-d', dirArg, '-t', 'C,,', '-i', certPath, '-n', 'devcert']);
} catch (e) {
debug(`failed to remove ${ certPath } from ${ dir }, continuing. ${ e.toString() }`)
}
}
debug(`finished scanning & installing certificate in NSS databases in ${ nssDirGlob }`);
}
/**
* Check to see if Firefox is still running, and if so, ask the user to close
* it. Poll until it's closed, then return.
*
* This is needed because Firefox appears to load the NSS database in-memory on
* startup, and overwrite on exit. So we have to ask the user to quite Firefox
* first so our changes don't get overwritten.
*/
export async function closeFirefox(): Promise<void> {
if (isFirefoxOpen()) {
await UI.closeFirefoxBeforeContinuing();
while(isFirefoxOpen()) {
await sleep(50);
}
}
}
/**
* Check if Firefox is currently open
*/
function isFirefoxOpen() {
// NOTE: We use some Windows-unfriendly methods here (ps) because Windows
// never needs to check this, because it doesn't update the NSS DB
// automaticaly.
assert(isMac || isLinux, 'checkForOpenFirefox was invoked on a platform other than Mac or Linux');
return exec('ps aux').indexOf('firefox') > -1;
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Firefox manages it's own trust store for SSL certificates, which can be
* managed via the certutil command (supplied by NSS tooling packages). In the
* event that certutil is not already installed, and either can't be installed
* (Windows) or the user doesn't want to install it (skipCertutilInstall:
* true), it means that we can't programmatically tell Firefox to trust our
* root CA certificate.
*
* There is a recourse though. When a Firefox tab is directed to a URL that
* responds with a certificate, it will automatically prompt the user if they
* want to add it to their trusted certificates. So if we can't automatically
* install the certificate via certutil, we instead start a quick web server
* and host our certificate file. Then we open the hosted cert URL in Firefox
* to kick off the GUI flow.
*
* This method does all this, along with providing user prompts in the terminal
* to walk them through this process.
*/
export async function openCertificateInFirefox(firefoxPath: string, certPath: string): Promise<void> {
debug('Adding devert to Firefox trust stores manually. Launching a webserver to host our certificate temporarily ...');
let port: number;
const server = http.createServer(async (req, res) => {
let { pathname } = new URL(req.url);
if (pathname === '/certificate') {
res.writeHead(200, { 'Content-type': 'application/x-x509-ca-cert' });
res.write(fs.readFileSync(certPath));
res.end();
} else {
res.writeHead(200);
res.write(await UI.firefoxWizardPromptPage(`http://localhost:${port}/certificate`));
res.end();
}
});
port = await new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(() => {
resolve((server.address() as net.AddressInfo).port);
});
});
try {
debug('Certificate server is up. Printing instructions for user and launching Firefox with hosted certificate URL');
await UI.startFirefoxWizard(`http://localhost:${port}`);
run(firefoxPath, [`http://localhost:${ port }`]);
await UI.waitForFirefoxWizard();
} finally {
server.close();
}
}
export function assertNotTouchingFiles(filepath: string, operation: string): void {
if (!filepath.startsWith(configDir) && !filepath.startsWith(getLegacyConfigDir())) {
throw new Error(`Devcert cannot ${ operation } ${ filepath }; it is outside known devcert config directories!`);
}
}

View File

@@ -0,0 +1,106 @@
import createDebug from 'debug';
import crypto from 'crypto';
import { rmSync as rm, writeFileSync as write, readFileSync as read } from 'fs';
import { Options } from '../index';
import { assertNotTouchingFiles, openCertificateInFirefox } from './shared';
import { Platform } from '.';
import { run, sudo } from '../utils';
import UI from '../user-interface';
const debug = createDebug('devcert:platforms:windows');
let encryptionKey: string;
export default class WindowsPlatform implements Platform {
private HOST_FILE_PATH = 'C:\\Windows\\System32\\Drivers\\etc\\hosts';
/**
* Windows is at least simple. Like macOS, most applications will delegate to
* the system trust store, which is updated with the confusingly named
* `certutil` exe (not the same as the NSS/Mozilla certutil). Firefox does it's
* own thing as usual, and getting a copy of NSS certutil onto the Windows
* machine to try updating the Firefox store is basically a nightmare, so we
* don't even try it - we just bail out to the GUI.
*/
async addToTrustStores(certificatePath: string, options: Options = {}): Promise<void> {
// IE, Chrome, system utils
debug('adding devcert root to Windows OS trust store')
try {
run('certutil', ['-addstore', '-user', 'root', certificatePath]);
} catch (e) {
e.output.map((buffer: Buffer) => {
if (buffer) {
console.log(buffer.toString());
}
});
}
debug('adding devcert root to Firefox trust store')
// Firefox (don't even try NSS certutil, no easy install for Windows)
try {
await openCertificateInFirefox('start firefox', certificatePath);
} catch {
debug('Error opening Firefox, most likely Firefox is not installed');
}
}
async removeFromTrustStores(certificatePath: string) {
debug('removing devcert root from Windows OS trust store');
try {
console.warn('Removing old certificates from trust stores. You may be prompted to grant permission for this. It\'s safe to delete old devcert certificates.');
run('certutil', ['-delstore', '-user', 'root', 'devcert']);
} catch (e) {
debug(`failed to remove ${ certificatePath } from Windows OS trust store, continuing. ${ e.toString() }`)
}
}
async addDomainToHostFileIfMissing(domain: string) {
let hostsFileContents = read(this.HOST_FILE_PATH, 'utf8');
if (!hostsFileContents.includes(domain)) {
await sudo(`echo 127.0.0.1 ${ domain } >> ${ this.HOST_FILE_PATH }`);
}
}
async deleteProtectedFiles(filepath: string) {
assertNotTouchingFiles(filepath, 'delete');
rm(filepath, { force: true, recursive: true });
}
async readProtectedFile(filepath: string): Promise<string> {
assertNotTouchingFiles(filepath, 'read');
if (!encryptionKey) {
encryptionKey = await UI.getWindowsEncryptionPassword();
}
// Try to decrypt the file
try {
return this.decrypt(read(filepath, 'utf8'), encryptionKey);
} catch (e) {
// If it's a bad password, clear the cached copy and retry
if (e.message.indexOf('bad decrypt') >= -1) {
encryptionKey = null;
return await this.readProtectedFile(filepath);
}
throw e;
}
}
async writeProtectedFile(filepath: string, contents: string) {
assertNotTouchingFiles(filepath, 'write');
if (!encryptionKey) {
encryptionKey = await UI.getWindowsEncryptionPassword();
}
let encryptedContents = this.encrypt(contents, encryptionKey);
write(filepath, encryptedContents);
}
private encrypt(text: string, key: string) {
let cipher = crypto.createCipher('aes256', Buffer.from(key));
return cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
}
private decrypt(encrypted: string, key: string) {
let decipher = crypto.createDecipher('aes256', Buffer.from(key));
return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
}
}

View File

@@ -0,0 +1,90 @@
import readline from 'node:readline';
import { waitForUser } from './utils';
export interface UserInterface {
getWindowsEncryptionPassword(): Promise<string>;
warnChromeOnLinuxWithoutCertutil(): Promise<void>;
closeFirefoxBeforeContinuing(): Promise<void>;
startFirefoxWizard(certificateHost: string): Promise<void>;
firefoxWizardPromptPage(certificateURL: string): Promise<string>;
waitForFirefoxWizard(): Promise<void>;
}
async function passwordPrompt(prompt: string): Promise<string> {
const input = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve, reject) => {
input.on('SIGINT', () => {
reject(new Error('SIGINT'));
});
input.question(prompt, (answer) => {
readline.moveCursor(process.stdout, 0, -1);
readline.clearLine(process.stdout, 0);
input.write(prompt + answer.replace(/./g, '*') + '\n');
input.close();
resolve(answer);
});
});
}
const DefaultUI: UserInterface = {
async getWindowsEncryptionPassword() {
return await passwordPrompt('devcert password (http://bit.ly/devcert-what-password?):');
},
async warnChromeOnLinuxWithoutCertutil() {
console.warn(`
WARNING: It looks like you have Chrome installed, but you specified
'skipCertutilInstall: true'. Unfortunately, without installing
certutil, it's impossible get Chrome to trust devcert's certificates
The certificates will work, but Chrome will continue to warn you that
they are untrusted.
`);
},
async closeFirefoxBeforeContinuing() {
console.log('Please close Firefox before continuing');
},
async startFirefoxWizard(certificateHost) {
console.log(`
devcert was unable to automatically configure Firefox. You'll need to
complete this process manually. Don't worry though - Firefox will walk
you through it.
When you're ready, hit any key to continue. Firefox will launch and
display a wizard to walk you through how to trust the devcert
certificate. When you are finished, come back here and we'll finish up.
(If Firefox doesn't start, go ahead and start it and navigate to
${ certificateHost } in a new tab.)
If you are curious about why all this is necessary, check out
https://github.com/davewasmer/devcert#how-it-works
<Press any key to launch Firefox wizard>
`);
await waitForUser();
},
async firefoxWizardPromptPage(certificateURL: string) {
return `
<html>
<head>
<meta http-equiv="refresh" content="0; url=${certificateURL}" />
</head>
</html>
`;
},
async waitForFirefoxWizard() {
console.log(`
Launching Firefox ...
Great! Once you've finished the Firefox wizard for adding the devcert
certificate, just hit any key here again and we'll wrap up.
<Press any key to continue>
`)
await waitForUser();
}
}
export default DefaultUI;

View File

@@ -0,0 +1,78 @@
import { execFileSync, ExecFileSyncOptions } from 'child_process';
import { randomBytes } from 'crypto';
import fs from 'fs';
import os from 'os';
import createDebug from 'debug';
import path from 'path';
import sudoPrompt from '@expo/sudo-prompt';
import { configPath, isWindows } from './constants';
const debug = createDebug('devcert:util');
export function openssl(args: string[]) {
return run('openssl', args, {
stdio: 'pipe',
env: Object.assign({
RANDFILE: path.join(configPath('.rnd'))
}, process.env)
});
}
export function run(cmd: string, args: string[], options: ExecFileSyncOptions = {}) {
debug(`execFileSync: \`${ cmd } ${args.join(' ')}\``);
return execFileSync(cmd, args, options);
}
export function sudoAppend(file: string, input: ExecFileSyncOptions["input"]) {
run('sudo', ['tee', '-a', file], {
input
});
}
export function waitForUser() {
return new Promise((resolve) => {
process.stdin.resume();
process.stdin.on('data', resolve);
});
}
export function reportableError(message: string) {
return new Error(`${message} | This is a bug in devcert, please report the issue at https://github.com/davewasmer/devcert/issues`);
}
export function mktmp() {
const random = randomBytes(6).toString('hex');
const tmppath = path.join(os.tmpdir(), `tmp-${process.pid}${random}`);
fs.closeSync(fs.openSync(tmppath, 'w'));
return tmppath;
}
export function sudo(cmd: string): Promise<string | null> {
return new Promise((resolve, reject) => {
sudoPrompt.exec(cmd, { name: 'devcert' }, (err: Error | null, stdout: string | null, stderr: string | null) => {
let error = err || (typeof stderr === 'string' && stderr.trim().length > 0 && new Error(stderr)) ;
error ? reject(error) : resolve(stdout);
});
});
}
const _commands: Record<string, string | null> = {};
export function commandExists(command: string): string | null {
if (_commands[command] !== undefined) {
return _commands[command];
}
const paths = process.env[isWindows ? 'Path' : 'PATH'].split(path.delimiter);
const extensions = [...(process.env.PATHEXT || '').split(path.delimiter), ''];
for (const dir of paths) {
for (const extension of extensions) {
const filePath = path.join(dir, command + extension);
try {
fs.accessSync(filePath, fs.constants.X_OK);
return (_commands[command] = filePath);
} catch {}
}
}
return (_commands[command] = null);
}