--- /dev/null
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+jobs:
+ release:
+ name: Release on GitHub
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: softprops/action-gh-release@v3
+ with:
+ generate_release_notes: true
+
+ publish:
+ name: Publish to npm
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: ".node-version"
+ registry-url: "https://registry.npmjs.org"
+ - run: npm install -g npm@latest
+ - run: npm ci
+ - run: npm publish --provenance --access public
\ No newline at end of file
--- /dev/null
+node_modules
+types
\ No newline at end of file
--- /dev/null
+26.1.0
\ No newline at end of file
--- /dev/null
+MIT License
+
+Copyright (c) 2026 Ardis Lu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
--- /dev/null
+# envpass
+
+A CLI and library to encrypt your `.env` file **using a passkey** and decrypt it when needed during application execution.
+
+If you want to encrypt using a normal private key, check out [dotenvx](https://github.com/dotenvx/dotenvx).
+
+Code repository mirrors: [GitHub](https://github.com/ardislu/envpass), [Codeberg](https://codeberg.org/ardislu/envpass), [git.ardis.lu](https://git.ardis.lu/envpass)
+
+## Installation
+
+```
+npm install @ardislu/envpass
+```
+
+## Basic usage
+
+1. To encrypt and overwrite your `.env` file with the encrypted values, run:
+
+```
+npx envpass encrypt
+```
+
+This command will automatically open a web browser to trigger the "sign in with passkey" flow. Create a new passkey or select an existing one to encrypt your variables.
+
+2. At the beginning of your code, add this line:
+
+```javascript
+import '@ardislu/envpass/decrypt';
+```
+
+This is a side effect only import which will run the `decrypt` function with default settings. When it is run, it will open a web browser to trigger the "sign in with passkey" flow where you can select the same passkey you used to encrypt your variables. It will use the passkey to decrypt and set the variables in `process.env` for the remainder of the process.
+
+3. To decrypt and overwrite your `.env` file with the decrypted values (i.e., undo `npx envpass encrypt`), run:
+
+```
+npx envpass decrypt
+```
+
+## In-process usage
+
+If you can't or don't want to install the package in your project, you can install `envpass` globally and then decrypt on a process-by-process basis:
+
+1. Install `envpass` globally:
+
+```
+npm i -g @ardislu/envpass
+```
+
+2. Decrypt using the `--inject-in-process` (`-i`) flag (and optionally `--silent`/`-s`) before you run your desired command:
+
+```
+envpass decrypt -is -- node index.js
+```
+
+## CLI reference
+
+```
+Usage: envpass [options] [command]
+
+encrypt and decrypt your .env file using passkeys
+
+Options:
+ -V, --version output the version number
+ -h, --help display help for command
+
+Commands:
+ encrypt [options] encrypt your .env file with a passkey
+ decrypt [options] decrypt your .env file with a passkey
+ help [command] display help for command
+```
+
+```
+Usage: envpass encrypt [options]
+
+encrypt your .env file with a passkey
+
+Options:
+ --in-file <path> path to the unencrypted .env file (default: '.env')
+ --out-file <path> path to write the encrypted .env file to (default: value of --in-file)
+ -s, --silent do not log anything to console
+ -h, --help display help for command
+```
+
+```
+Usage: envpass decrypt [options]
+
+decrypt your .env file with a passkey
+
+Options:
+ --in-file <path> path to the encrypted .env file (default: '.env')
+ --out-file <path> path to write the decrypted .env file to (default: value of --in-file)
+ -i, --inject-in-process inject the decrypted environment variables to process.env (and ignore --out-file)
+ -s, --silent do not log anything to console
+ -h, --help display help for command
+```
+
+## Library reference
+
+```javascript
+import { encrypt, decrypt } from '@ardislu/envpass';
+
+await encrypt({
+ // ...
+});
+
+await decrypt({
+ // ...
+});
+```
+
+## Why?
+
+Encrypting your `.env` is defense in depth that helps to mitigate supply chain attacks where an attacker has injected code to your local environment. Most passkey implementations require active user participation to decrypt, which will help prevent attackers from getting secrets via passive extraction by scripts.
+
+You should still treat your encrypted `.env` as a normal secret `.env` file, meaning do **NOT** share or upload your `.env` anywhere. `envpass` is meant to supplement current security practices, not to replace them.
--- /dev/null
+import { copyFile, unlink } from 'node:fs/promises';
+
+await copyFile('./package.backup.json', './package.json');
+await unlink('./package.backup.json');
\ No newline at end of file
--- /dev/null
+import { existsSync } from 'node:fs';
+import { copyFile } from 'node:fs/promises';
+import { execSync } from 'node:child_process';
+
+// If called with `npm run pack:dev`, then this code will be executed TWICE:
+// node prepack.js -- --dev
+// node prepack.js
+// npm pack
+// node postpack.js
+// If called with `npm pack`, then this code will be executed only ONCE:
+// node prepack.js
+// npm pack
+// node postpack.js
+// This is the simplest cross-platform solution without (1) using environment variables (non-trivial
+// to make work across platforms) or (2) user-defined npm config variables (deprecated in npm v11 and
+// will be removed in npm v12).
+
+// First pass if `npm run pack:dev`
+if (process.argv.slice(2).includes('--dev')) {
+ await copyFile('./package.json', './package.backup.json');
+ execSync(`npm pkg set version=0.0.0-DEV-${Date.now()}`);
+ execSync('npm pkg set private=true');
+}
+// Second pass if `npm run pack:dev` OR first pass if `npm pack`
+else {
+ if (!existsSync('./package.backup.json')) {
+ await copyFile('./package.json', './package.backup.json');
+ }
+ execSync('npm run types');
+ execSync('npm pkg delete devEngines');
+ execSync('npm pkg delete scripts');
+ execSync('npm pkg delete devDependencies');
+}
\ No newline at end of file
--- /dev/null
+{
+ "name": "@ardislu/envpass",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@ardislu/envpass",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.3",
+ "open": "^11.0.0"
+ },
+ "bin": {
+ "envpass": "src/cli.js"
+ },
+ "devDependencies": {
+ "@types/node": "^25.8.0",
+ "playwright-core": "^1.60.0",
+ "typescript": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.8.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
+ "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/default-browser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ssh": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
+ "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
+ "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.4.0",
+ "define-lazy-prop": "^3.0.0",
+ "is-in-ssh": "^1.0.0",
+ "is-inside-container": "^1.0.0",
+ "powershell-utils": "^0.1.0",
+ "wsl-utils": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/powershell-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+ "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wsl-utils": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+ "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0",
+ "powershell-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
--- /dev/null
+{
+ "name": "@ardislu/envpass",
+ "version": "0.1.0",
+ "description": "A CLI and library to encrypt your .env file using a passkey and decrypt it when needed during application execution.",
+ "keywords": [
+ "env",
+ "dotenv",
+ "passkey"
+ ],
+ "author": "Ardis Lu",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ardislu/envpass.git"
+ },
+ "license": "MIT",
+ "type": "module",
+ "engines": {
+ "node": ">=20"
+ },
+ "devEngines": {
+ "runtime": {
+ "name": "node",
+ "version": ">=26.0.0"
+ },
+ "packageManager": {
+ "name": "npm",
+ "version": ">=11.12.1"
+ }
+ },
+ "files": [
+ "./types/*",
+ "./src/*"
+ ],
+ "imports": {
+ "#src/*.js": "./src/*.js",
+ "#test/*.js": "./test/*.js"
+ },
+ "exports": {
+ ".": {
+ "types": "./types/index.d.ts",
+ "default": "./src/index.js"
+ },
+ "./decrypt": {
+ "default": "./src/decrypt.js"
+ }
+ },
+ "bin": {
+ "envpass": "./src/cli.js"
+ },
+ "scripts": {
+ "types": "tsc",
+ "types:check": "tsc --noEmit --checkJS",
+ "pretest": "playwright-core install",
+ "test": "node --test",
+ "test:coverage": "npm run test -- --experimental-test-coverage",
+ "prepack": "node ./npm/prepack.js",
+ "postpack": "node ./npm/postpack.js",
+ "pack:dev": "npm run prepack -- --dev && npm pack",
+ "start": "node ./src/cli.js"
+ },
+ "dependencies": {
+ "commander": "^14.0.3",
+ "open": "^11.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.8.0",
+ "playwright-core": "^1.60.0",
+ "typescript": "^6.0.3"
+ }
+}
--- /dev/null
+#!/usr/bin/env node
+
+import { Command } from 'commander';
+import { encrypt, decrypt } from '#src/index.js';
+
+/**
+ * Create the CLI program instance.
+ * @param {typeof encrypt} encryptAction Action handler for the `encrypt` command.
+ * @param {typeof decrypt} decryptAction Action handler for the `decrypt` command.
+ * @returns {Command} The initialized CLI command. Call `.parse()` or `.parseAsync()` to execute from CLI args.
+ */
+export function makeProgram(encryptAction, decryptAction) {
+ const program = new Command();
+
+ program
+ .name('envpass')
+ .description('encrypt and decrypt your .env file using passkeys')
+ .version('0.1.0');
+
+ program.command('encrypt')
+ .description('encrypt your .env file with a passkey')
+ .option('--in-file <path>', "path to the unencrypted .env file (default: '.env')")
+ .option('--out-file <path>', 'path to write the encrypted .env file to (default: value of --in-file)')
+ .option('-s, --silent', 'do not log anything to console')
+ .action(encryptAction);
+
+ program.command('decrypt')
+ .description('decrypt your .env file with a passkey')
+ .option('--in-file <path>', "path to the encrypted .env file (default: '.env')")
+ .option('--out-file <path>', 'path to write the decrypted .env file to (default: value of --in-file)')
+ .option('-i, --inject-in-process', 'inject the decrypted environment variables to process.env (and ignore --out-file)')
+ .option('-s, --silent', 'do not log anything to console')
+ .allowExcessArguments(true)
+ .action(decryptAction);
+
+ return program;
+}
+
+/* node:coverage ignore next 1 */
+if (import.meta.main) { makeProgram(encrypt, decrypt).parse(); }
\ No newline at end of file
--- /dev/null
+import { parseEnv, styleText } from 'node:util';
+import { readFile, writeFile } from 'node:fs/promises';
+import { execSync } from 'node:child_process';
+/** @import { PathLike } from 'node:fs'; */
+
+import { getPrf } from '#src/getPrf.js';
+import { encryptRaw, decryptRaw } from '#src/crypto.js';
+import { toEnvString, parseEnvpassEncrypted, InputError } from '#src/utils.js';
+/** @import { GetPrfOptions } from '#src/getPrf.js'; */
+/** @import { Env } from '#src/utils.js'; */
+
+/**
+ * @typedef {Object} EncryptOptions
+ * @property {PathLike} [inFile] Path to the unencrypted .env file. The default value is `'.env'`.
+ * @property {PathLike|false} [outFile] Path to write the encrypted .env file to, or `false` to not write to any
+ * file. The default value is set to the value of `inFile` (i.e., *overwrite* the `inFile` with encrypted values).
+ * @property {'ignore'|'log'|'encrypt'|'error'} [alreadyEncryptedValue] What to do when encountering a value
+ * that is already envpass encrypted (i.e., the value is already in the format `'envpass:{version}:{string}'`).
+ * - `'ignore'` will not encrypt the value and instead passthrough the value as-is.
+ * - `'log'` will do the same as `'ignore'`, but also log the variable name to console.
+ * - `'encrypt'` will encrypt the value again anyway.
+ * - `'error'` will throw an error.
+ *
+ * The default value is `'ignore'`.
+ * @property {GetPrfOptions} [getPrfOptions] Options for the passkey page and server.
+ * @property {boolean} [silent] If `true`, do not log to console. The default value is `false`.
+ */
+
+/**
+ * Encrypt a .env file using a passkey.
+ * @param {EncryptOptions} [options] Options to configure the encryption.
+ * @returns {Promise<void>} `Promise` resolving when the encryption is complete.
+ */
+export async function encrypt(options = {}) {
+ const {
+ inFile = '.env',
+ outFile = inFile,
+ alreadyEncryptedValue = 'ignore',
+ getPrfOptions = { autoOpen: true, port: undefined },
+ silent = false
+ } = options;
+
+ const env = await readFile(inFile, { encoding: 'utf-8' }).then(parseEnv);
+ if (Object.keys(env).length === 0) {
+ if (!silent) { console.log('No environment variables found, aborting.'); }
+ return;
+ }
+
+ const { url, prf: prfPromise } = await getPrf(getPrfOptions);
+ if (!getPrfOptions.autoOpen) {
+ if (!silent) { console.log(`Open this page in a web browser to complete the passkey flow: ${url}`); }
+ }
+ const prf = await prfPromise;
+ if (prf === null) {
+ throw new InputError('Unable to get passkey.');
+ }
+
+ /** @type {Env} */const encryptedEnv = Object.create(null);
+ for (const [envVar, value] of Object.entries(env)) {
+ if (value === undefined) {
+ if (!silent) { console.log(`Environment variable "${envVar}" is unset, skipping encryption.`); }
+ encryptedEnv[envVar] = '';
+ continue;
+ }
+
+ const parsedValue = parseEnvpassEncrypted(value);
+ if (parsedValue !== null) {
+ encryptedEnv[envVar] = parsedValue;
+ switch (alreadyEncryptedValue) {
+ case 'ignore': continue;
+ case 'log': if (!silent) { console.log(`Environment variable "${envVar}" is already encrypted, ignoring.`); } continue;
+ case 'encrypt': break;
+ case 'error': throw new Error(`Environment variable "${envVar}" is already encrypted.`);
+ }
+ }
+
+ const encryptedValue = await encryptRaw({ prf, value });
+ encryptedEnv[envVar] = encryptedValue;
+ }
+
+ if (outFile) {
+ await writeFile(outFile, toEnvString(encryptedEnv));
+ }
+ if (!silent) {
+ console.log(styleText('green', '🎉 Successfully encrypted environment variables!'));
+ }
+}
+
+/**
+ * @typedef {Object} DecryptOptions
+ * @property {PathLike} [inFile] Path to the encrypted .env file. The default value is `'.env'`.
+ * @property {PathLike|false} [outFile] Path to write the unencrypted .env file to, or `false` to not write to any
+ * file. The default value is set to the value of `inFile` (i.e., *overwrite* the `inFile` with unencrypted values).
+ * @property {boolean} [injectInProcess] If `true`, ignore `outFile` and set keys in `process.env` with the decrypted
+ * values. The default value is `false`.
+ * @property {'ignore'|'log'|'error'} [notEncryptedValue] What to do when encountering a value
+ * that is not envpass encrypted (i.e., the value is not in the format `'envpass:{version}:{string}'`).
+ * - `'ignore'` will passthrough the value as-is.
+ * - `'log'` will do the same as `'ignore'`, but also log the variable name to console.
+ * - `'error'` will throw an error.
+ *
+ * The default value is `'ignore'`.
+ * @property {GetPrfOptions} [getPrfOptions] Options for the passkey page and server.
+ * @property {boolean} [silent] If `true`, do not log to console. The default value is `false`.
+ */
+
+/**
+ * Decrypt an envpass encrypted .env file using a passkey.
+ * @param {DecryptOptions} [options] Options to configure the decryption.
+ * @param {{ args?: Array<string> }} [command] Excess arguments passed to the command, which will be executed as
+ * a child process after decryption.
+ * @returns {Promise<void>} `Promise` resolving when the decryption is complete.
+ */
+export async function decrypt(options = {}, { args = [] } = {}) {
+ const {
+ inFile = '.env',
+ outFile = inFile,
+ injectInProcess = false,
+ notEncryptedValue = 'ignore',
+ getPrfOptions = { autoOpen: true, port: undefined },
+ silent = false
+ } = options;
+
+ const env = await readFile(inFile, { encoding: 'utf-8' }).then(parseEnv);
+ if (Object.keys(env).length === 0) {
+ if (!silent) { console.log('No environment variables found, aborting.'); }
+ return;
+ }
+
+ const { url, prf: prfPromise } = await getPrf(getPrfOptions);
+ if (!getPrfOptions.autoOpen) {
+ if (!silent) { console.log(`Open this page in a web browser to complete the passkey flow: ${url}`); }
+ }
+ const prf = await prfPromise;
+ if (prf === null) {
+ throw new InputError('Unable to get passkey.');
+ }
+
+ /** @type {Env} */const decryptedEnv = {};
+ for (const [envVar, value] of Object.entries(env)) {
+ if (value === undefined) {
+ if (!silent) { console.log(`Environment variable "${envVar}" is unset, skipping decryption.`); }
+ decryptedEnv[envVar] = '';
+ continue;
+ }
+
+ const parsedValue = parseEnvpassEncrypted(value);
+ if (parsedValue === null) {
+ decryptedEnv[envVar] = value;
+ switch (notEncryptedValue) {
+ case 'ignore': continue;
+ case 'log': if (!silent) { console.log(`Environment variable "${envVar}" is not encrypted, ignoring.`); } continue;
+ case 'error': throw new Error(`Environment variable "${envVar}" is not encrypted.`);
+ }
+ }
+ const decryptedValue = await decryptRaw({ prf, value: parsedValue });
+ decryptedEnv[envVar] = decryptedValue;
+ }
+
+ if (injectInProcess) {
+ for (const [envVar, value] of Object.entries(decryptedEnv)) {
+ process.env[envVar] = value;
+ }
+ }
+ else {
+ if (outFile) {
+ await writeFile(outFile, toEnvString(decryptedEnv));
+ }
+ }
+ if (!silent) {
+ console.log(styleText('green', '🎉 Successfully decrypted environment variables!'));
+ }
+ if (args.length > 0) {
+ execSync(args.join(' '), { stdio: 'inherit' });
+ }
+}
\ No newline at end of file
--- /dev/null
+/** @import { EnvpassVersion, EnvpassEncryptedValue } from '#src/utils.js'; */
+
+/**
+ * Create a `CryptoKey` from a high-entropy random value using HKDF.
+ * @param {ArrayBuffer|ArrayBufferView<ArrayBuffer>} rand A high-entropy random value.
+ * @param {ArrayBuffer|ArrayBufferView<ArrayBuffer>} salt A cryptographic salt value.
+ * @param {ArrayBuffer|ArrayBufferView<ArrayBuffer>} info Additional contextual information.
+ * @returns {Promise<CryptoKey>} A `CryptoKey` that can be used for `"encrypt"` or `"decrypt"`.
+ */
+async function getKey(rand, salt, info) {
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ rand,
+ 'HKDF',
+ false,
+ ['deriveKey']
+ );
+
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: 'HKDF',
+ hash: 'SHA-512',
+ salt,
+ info
+ },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ );
+
+ return key;
+}
+
+/**
+ * @typedef {Object} EncryptRawOptions
+ * @property {EnvpassVersion} [version] Encryption version to use. Currently, only `'v1'` is supported. The default
+ * value is `'v1'`.
+ * @property {ArrayBuffer|ArrayBufferView<ArrayBuffer>} prf Value returned by the passkey's pseudo-random function,
+ * which will be used to derive the cryptographic key to encrypt the variables.
+ * @property {string} value Plaintext value to encrypt.
+ */
+
+/**
+ * Encrypt a given plaintext value using the value returned from a passkey's pseudo random function.
+ * @param {EncryptRawOptions} options
+ * @returns {Promise<EnvpassEncryptedValue>} Encrypted envpass value.
+ * @throws {TypeError} If an invalid option is passed.
+ */
+export async function encryptRaw(options) {
+ const { version = 'v1', prf, value } = options;
+
+ if (!ArrayBuffer.isView(prf) && !(prf instanceof ArrayBuffer)) {
+ throw new TypeError(`prf "${prf}" is not an ArrayBuffer or ArrayBufferView.`);
+ }
+
+ if (typeof value !== 'string') {
+ throw new TypeError(`Value "${value}" is not a string.`);
+ }
+
+ if (version === 'v1') {
+ // Generate all required random values
+ const salt = crypto.getRandomValues(new Uint8Array(64)); // For HKDF
+ const iv = crypto.getRandomValues(new Uint8Array(12)); // For AES-GCM
+
+ // Prepare non-crypto inputs
+ const encoder = new TextEncoder();
+ const encodedPlaintext = encoder.encode(value);
+ const info = encoder.encode('https://github.com/ardislu/envpass');
+
+ // Pad ciphertext so the final buffer length is a multiple of 3, to remove base64 padding. Not cryptographically
+ // significant, just for aesthetics. +16 assumes ciphertext includes a 128 bit AES-GCM authentication tag.
+ const p = 3 - ((salt.byteLength + iv.byteLength + encodedPlaintext.byteLength + 16) % 3);
+ const paddedPlaintext = new Uint8Array(encodedPlaintext.byteLength + p);
+ paddedPlaintext.set([p]); // Offset to discard on decode
+ paddedPlaintext.set(encodedPlaintext, p); // Gap filled with zero bytes, e.g. [3, 0, 0, ...encodedPlaintext]
+
+ const key = await getKey(prf, salt, info);
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ paddedPlaintext
+ ));
+
+ const buffer = new Uint8Array(salt.byteLength + iv.byteLength + ciphertext.byteLength);
+ buffer.set(salt);
+ buffer.set(iv, salt.byteLength);
+ buffer.set(ciphertext, salt.byteLength + iv.byteLength);
+
+ return `envpass:${version}:${buffer.toBase64()}`;
+ }
+
+ throw new TypeError(`envpass version "${version}" is not supported.`);
+}
+
+/**
+ * @typedef {Object} DecryptRawOptions
+ * @property {ArrayBuffer|ArrayBufferView<ArrayBuffer>} prf Value returned by the passkey's pseudo-random function,
+ * which will be used to derive the cryptographic key to decrypt the variables.
+ * @property {EnvpassEncryptedValue} value Encrypted ciphertext value to decrypt.
+ */
+
+/**
+ * Encrypt an envpass encrypted value using the value returned from a passkey's pseudo random function.
+ * @param {DecryptRawOptions} options
+ * @returns {Promise<string>} Decrypted plaintext value.
+ * @throws {TypeError} If an invalid option is passed.
+ */
+export async function decryptRaw(options) {
+ const { prf, value } = options;
+ const [magic, version, valueBase64] = value.split(':');
+
+ if (!ArrayBuffer.isView(prf) && !(prf instanceof ArrayBuffer)) {
+ throw new TypeError(`prf "${prf}" is not an ArrayBuffer or ArrayBufferView.`);
+ }
+
+ if (magic !== 'envpass') {
+ throw new TypeError(`Value "${value}" is not an envpass encrypted value.`);
+ }
+
+ if (version === 'v1') {
+ // Extract random values
+ const buffer = Uint8Array.fromBase64(valueBase64);
+ const salt = buffer.slice(0, 64); // For HKDF
+ const iv = buffer.slice(64, 76); // For AES-GCM
+ const ciphertext = buffer.slice(76);
+
+ // Prepare non-crypto inputs
+ const info = new TextEncoder().encode('https://github.com/ardislu/envpass');
+
+ const key = await getKey(prf, salt, info);
+ const encodedPlaintext = new Uint8Array(await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ ciphertext
+ ));
+ const unpaddedPlaintext = encodedPlaintext.slice(encodedPlaintext[0]);
+
+ const plaintext = new TextDecoder().decode(unpaddedPlaintext);
+
+ return plaintext;
+ }
+
+ throw new TypeError(`envpass version "${version}" is not supported.`);
+}
\ No newline at end of file
--- /dev/null
+/**
+ * This file is intended to be imported as a side effect only import to support the most common use case
+ * of decrypting .env and injecting the decrypted variables into the running program.
+ *
+ * To customize the decrypt options, import and call the `decrypt` function directly in your code like this:
+ *
+ * import { decrypt } from '@ardislu/envpass';
+ * await decrypt({
+ * // ...
+ * });
+ */
+
+import { decrypt } from '#src/index.js';
+
+await decrypt({
+ outFile: false,
+ injectInProcess: true,
+ silent: true
+});
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="icon" href="data:,">
+ <title>envpass</title>
+ <style>
+ :root {
+ color-scheme: light dark;
+ --bg: light-dark(hsl(216deg 50% 90%), hsl(216deg 40% 20%));
+ --text: light-dark(hsl(216deg 50% 10%), hsl(216deg 50% 90%));
+ --a: light-dark(hsl(216deg 100% 45%), hsl(216deg 100% 60%))
+ }
+
+ body {
+ font-family: system-ui, sans-serif;
+ min-block-size: 100dvb;
+ margin-inline: 16px;
+ display: grid;
+ place-items: center;
+ background: var(--bg);
+ color: var(--text);
+ }
+
+ p {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: 600;
+ text-align: center;
+ }
+
+ .link {
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--a);
+ font: inherit;
+ cursor: pointer;
+ font-weight: 700;
+ text-decoration: none 6px;
+ text-underline-offset: 8px;
+
+ &:hover {
+ text-decoration-line: underline;
+ }
+ }
+ </style>
+</head>
+
+<body>
+ <p>Create or select a passkey, or <button class="link">click here</button> if no prompt appeared. This window will expire in 2 minutes.</p>
+
+ <script type="module">
+ // Extract and delete challenge from URL
+ const { challenge } = Object.fromEntries(new URLSearchParams(location.search));
+ history.replaceState(null, '', location.pathname);
+
+ const p = document.querySelector('p');
+
+ const WINDOW_EXPIRATION_DURATION = 2 * 60 * 1000; // 2 minutes in milliseconds
+ const timeoutId = setTimeout(() => p.textContent = 'This window has expired.', WINDOW_EXPIRATION_DURATION);
+
+ async function createPrf(signal) {
+ signal.throwIfAborted();
+ return navigator.credentials.create({
+ signal,
+ publicKey: {
+ rp: { name: '' },
+ user: { id: new ArrayBuffer(1), name: crypto.randomUUID(), displayName: '' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -8 }, { type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
+ extensions: { prf: { eval: { first: new ArrayBuffer(0) } } },
+ challenge: new ArrayBuffer(0),
+ authenticatorSelection: { residentKey: 'required' }
+ }
+ }).then(c => c.getClientExtensionResults().prf?.results?.first ?? getPrf());
+ }
+ async function getPrf(signal) {
+ signal.throwIfAborted();
+ return navigator.credentials.get({
+ signal,
+ mediation: 'conditional',
+ publicKey: {
+ extensions: { prf: { eval: { first: new ArrayBuffer(0) } } },
+ challenge: new ArrayBuffer(0)
+ }
+ }).then(c => c.getClientExtensionResults().prf.results.first);
+ }
+ async function broadcastPrf(controller) {
+ let prfBuffer;
+ try { prfBuffer = await getPrf(controller.signal); }
+ catch { prfBuffer = await createPrf(controller.signal); }
+ const prf = new Uint8Array(prfBuffer).toHex();
+ const response = await fetch(`http://localhost:${location.port}`, { // Intentionally hardcoding localhost, do not broadcast anywhere else
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ challenge, prf })
+ });
+ if (response.status === 200) {
+ p.textContent = 'Successfully sent passkey. You may close this window now.';
+ clearTimeout(timeoutId);
+ }
+ }
+
+ let controller = new AbortController();
+ let broadcastPromise;
+ document.querySelector('button').addEventListener('click', async e => {
+ e.preventDefault();
+ controller.abort('New request initiated');
+ await broadcastPromise.catch(() => {}); // Error is expected after aborting
+ controller = new AbortController();
+ broadcastPromise = broadcastPrf(controller);
+ });
+ broadcastPromise = broadcastPrf(controller);
+ </script>
+</body>
+
+</html>
\ No newline at end of file
--- /dev/null
+import http from 'node:http';
+import { readFile } from 'node:fs/promises';
+/** @import { AddressInfo } from 'node:net'; */
+
+import open from 'open';
+
+import { parsePrf } from '#src/utils.js';
+/** @import { Bytes32 } from '#src/utils.js'; */
+
+/**
+ * Wrapper around browser operations (currently, only `open`). This wrapper object is required
+ * to mock the methods for testing purposes.
+ */
+export const BROWSER = { open };
+
+/**
+ * 2 minutes in milliseconds. After this duration, the http server will close and the flow
+ * must be restarted.
+ */
+export const WINDOW_EXPIRATION_DURATION = 2 * 60 * 1000;
+
+/**
+ * @typedef {Object} GetPrfOptions
+ * @property {boolean} [autoOpen] Automatically open a web browser to the passkey page. The default
+ * value is `true`.
+ * @property {number} [port] IP port number to use for the local server hosting the passkey page. By
+ * default, defer to the operating system to assign a number.
+ * @property {AbortSignal} [signal] `AbortSignal` which may be used to cancel this passkey flow.
+ */
+
+/**
+ * @typedef {Object} GetPrfResult
+ * @property {string} url URL on `localhost` to open to complete the passkey flow.
+ * @property {Promise<Bytes32|null>} prf 32-byte value returned from the user passkey's pseudo random
+ * function, or `null` if the flow was aborted or timed out before the PRF was executed.
+ */
+
+/**
+ * Create a minimal HTTP server to coordinate an OAuth-like workflow to get a passkey's PRF value.
+ *
+ * This function will open a web browser to a minimal HTML page that prompts the user to create or get a passkey
+ * on `localhost`, run the passkey's pseudo random function, then pass the value back to the server.
+ * @param {GetPrfOptions} [options] Options for the passkey page and server.
+ * @returns {Promise<GetPrfResult>} Object containing the URL to complete the passkey flow and a `Promise` for
+ * the passkey's PRF value.
+ */
+export async function getPrf(options = {}) {
+ const {
+ autoOpen = true,
+ port = undefined,
+ signal = undefined
+ } = options;
+
+ // MUST immediately throw if already aborted to prevent conflicting passkey flows
+ if (signal !== undefined) { signal.throwIfAborted(); }
+
+ // Setup all promises
+ const { promise: urlPromise, resolve: urlResolve } = /** @type {PromiseWithResolvers<string>}*/(Promise.withResolvers());
+ const { promise: prfPromise, resolve: prfResolve } = /** @type {PromiseWithResolvers<Bytes32>}*/(Promise.withResolvers());
+ const { promise: abortPromise, resolve: abortResolve } = /** @type {PromiseWithResolvers<null>}*/(Promise.withResolvers());
+
+ const html = await readFile(new URL('./getPrf.html', import.meta.url));
+ const server = http.createServer();
+
+ // Flow expires after 2 minutes or canceled
+ const timeoutId = setTimeout(() => server.close(() => abortResolve(null)), WINDOW_EXPIRATION_DURATION);
+ if (signal !== undefined) {
+ signal.addEventListener('abort', () => {
+ clearTimeout(timeoutId);
+ server.close(() => abortResolve(null));
+ }, { once: true });
+ }
+
+ // Challenge is used to verify the client POST-ing to the server was instantiated from this code
+ const challenge = crypto.getRandomValues(new Uint8Array(32)).toHex();
+
+ server.on('request', (request, response) => {
+ // Return minimal client-side code to get a passkey prf value
+ if (request.method === 'GET') {
+ const url = new URL(`http://localhost${request.url}`);
+ if (url.searchParams.get('challenge') !== challenge) {
+ response.writeHead(401).end();
+ return;
+ }
+ response
+ .writeHead(200, { 'Content-Type': 'text/html' })
+ .end(html);
+ return;
+ }
+ // Check if the payload has the correct challenge, and if so then use the prf
+ else if (request.method === 'POST') {
+ let body = '';
+ request.on('data', chunk => body += chunk);
+ request.on('end', () => {
+ const payload = JSON.parse(body);
+ if (payload.challenge !== challenge) {
+ response.writeHead(401).end();
+ return;
+ }
+ const prf = parsePrf(payload.prf);
+ if (prf === null) {
+ response.writeHead(400).end();
+ return;
+ }
+ response.writeHead(200).end();
+ server.close();
+ clearTimeout(timeoutId);
+ prfResolve(prf);
+ return;
+ });
+ }
+ });
+
+ server.listen(port, 'localhost');
+ server.on('listening', () => {
+ const { port } = /** @type {AddressInfo} */(server.address());
+ const url = `http://localhost:${port}?challenge=${challenge}`;
+ urlResolve(url);
+ if (autoOpen) { BROWSER.open(url); }
+ });
+
+ return {
+ url: await urlPromise,
+ prf: Promise.any([prfPromise, abortPromise])
+ };
+}
\ No newline at end of file
--- /dev/null
+export { encrypt, decrypt } from '#src/command.js';
\ No newline at end of file
--- /dev/null
+/**
+ * @typedef {{[key:string]:string|undefined}} Env A simple null prototype object dictionary to map environment
+ * variable names to values.
+ */
+
+/**
+ * Convert an `Env` object into a `string` ready to be written to an env file. Inverse of `parseEnv()`.
+ * @param {Env} obj Any object where all values are stringify-able.
+ * @returns {string} Parsed and normalized `string` ready to be written to an env file.
+ */
+export function toEnvString(obj) {
+ let str = '';
+ for (const [envVar, value] of Object.entries(obj)) {
+ str += `${envVar}=${value}\n`;
+ }
+ return str;
+}
+
+/**
+ * @typedef {Uint8Array<ArrayBuffer>&{byteLength:32}} Bytes32
+ */
+
+/**
+ * Returns whether `value` is `Bytes32` or not. This function is NOT intended to be directly called.
+ * Use `parsePrf` instead ("parse, don't validate").
+ *
+ * This function is only defined separately to make the `value is Bytes32` assertion for TypeScript.
+ * @param {any} value A value that may be a `Bytes32`.
+ * @returns {value is Bytes32}
+ */
+function isBytes32(value) {
+ return value instanceof Uint8Array && value.byteLength === 32;
+}
+
+/**
+ * Parse a string that may be an output from a passkey's pseudo random function encoded in hexadecimal.
+ * @param {string} value A string that may be a value generated from a passkey's pseudo random function.
+ * @returns {Bytes32|null} A 32 byte `Uint8Array`, or `null` if `value` is not a 64 character hexadecimal
+ * value.
+ */
+export function parsePrf(value) {
+ if (/^(0x)?[0-9a-f]{64}$/i.test(value)) {
+ const arr = Uint8Array.fromHex(value.replace(/^0x/i, ''));
+ if (isBytes32(arr)) {
+ return arr;
+ }
+ }
+ return null;
+}
+
+const ENVPASS_VERSIONS = /** @type {const} */(['v1']);
+/**
+ * @typedef {typeof ENVPASS_VERSIONS[number]} EnvpassVersion A valid version identifier for envpass.
+ */
+
+/**
+ * @typedef {`envpass:${EnvpassVersion}:${string}`} EnvpassEncryptedValue A value which has been encrypted by envpass,
+ * in the format `'envpass:{version}:{ciphertext}'`
+ */
+
+/**
+ * Returns whether `value` is `EnvpassEncryptedValue` or not. This function is NOT intended to be directly called.
+ * Use `parseEnvpassEncrypted` instead ("parse, don't validate").
+ *
+ * This function is only defined separately to make the `value is EnvpassEncryptedValue` assertion for TypeScript.
+ * @param {string} value A string that may have been encrypted by envpass.
+ * @returns {value is EnvpassEncryptedValue}
+ */
+function isEnvpassEncrypted(value) {
+ return new RegExp(`^envpass:${ENVPASS_VERSIONS.join('|')}:.+`).test(value);
+}
+
+/**
+ * Parses a string that may be in the format `'envpass:{EnvpassVersion}:{string}'` and returns the string
+ * it is valid, or `null` if it is not valid.
+ * @param {string} value A string that may have been encrypted by envpass.
+ * @returns {EnvpassEncryptedValue|null}
+ */
+export function parseEnvpassEncrypted(value) {
+ return isEnvpassEncrypted(value) ? value : null;
+}
+
+/**
+ * Invalid user input provided to the program.
+ */
+export class InputError extends Error {
+ /**
+ * @param {string} [message]
+ */
+ constructor(message) {
+ super(message);
+ this.name = 'InputError';
+ }
+}
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { deepStrictEqual, ok } from 'node:assert/strict';
+
+import { makeProgram } from '#src/cli.js';
+
+suite('cli', () => {
+ test('envpass encrypt works', (t) => {
+ const program = makeProgram(t.mock.fn(), t.mock.fn());
+ program.parse('node envpass encrypt'.split(' '));
+
+ const command = program.commands.find(c => c.name() === 'encrypt');
+ ok(command);
+ const { inFile, outFile, silent } = command.opts();
+ deepStrictEqual(inFile, undefined);
+ deepStrictEqual(outFile, undefined);
+ deepStrictEqual(silent, undefined);
+ });
+ test('envpass encrypt options work', (t) => {
+ const program = makeProgram(t.mock.fn(), t.mock.fn());
+ program.parse('node envpass encrypt --in-file TEST_IN --out-file TEST_OUT --silent'.split(' '));
+
+ const command = program.commands.find(c => c.name() === 'encrypt');
+ ok(command);
+ const { inFile, outFile, silent } = command.opts();
+ deepStrictEqual(inFile, 'TEST_IN');
+ deepStrictEqual(outFile, 'TEST_OUT');
+ deepStrictEqual(silent, true);
+ });
+ test('envpass decrypt works', (t) => {
+ const program = makeProgram(t.mock.fn(), t.mock.fn());
+ program.parse('node envpass decrypt'.split(' '));
+
+ const command = program.commands.find(c => c.name() === 'decrypt');
+ ok(command);
+ const { inFile, outFile, injectInProcess, silent } = command.opts();
+ deepStrictEqual(inFile, undefined);
+ deepStrictEqual(outFile, undefined);
+ deepStrictEqual(injectInProcess, undefined);
+ deepStrictEqual(silent, undefined);
+ });
+ test('envpass decrypt options work', (t) => {
+ const program = makeProgram(t.mock.fn(), t.mock.fn());
+ program.parse('node envpass decrypt --in-file TEST_IN --out-file TEST_OUT --inject-in-process --silent'.split(' '));
+
+ const command = program.commands.find(c => c.name() === 'decrypt');
+ ok(command);
+ const { inFile, outFile, injectInProcess, silent } = command.opts();
+ deepStrictEqual(inFile, 'TEST_IN');
+ deepStrictEqual(outFile, 'TEST_OUT');
+ deepStrictEqual(injectInProcess, true);
+ deepStrictEqual(silent, true);
+ });
+ test('envpass decrypt -is works', (t) => {
+ const program = makeProgram(t.mock.fn(), t.mock.fn());
+ program.parse('node envpass decrypt -is'.split(' '));
+
+ const command = program.commands.find(c => c.name() === 'decrypt');
+ ok(command);
+ const { injectInProcess, silent } = command.opts();
+ deepStrictEqual(injectInProcess, true);
+ deepStrictEqual(silent, true);
+ });
+});
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { deepStrictEqual, rejects } from 'node:assert/strict';
+
+import { encryptRaw, decryptRaw } from '#src/crypto.js';
+
+// Baseline values
+const version = 'v1';
+const prf = Uint8Array.fromHex('deadbeef000000000000000000000000000000000000000000000000cafebabe');
+const value = '🏴☠️ Hello, world!';
+
+suite('encryptRaw and decryptRaw', { concurrency: true }, () => {
+ test('works on simple string', { concurrency: true }, async () => {
+ deepStrictEqual(await encryptRaw({ version, prf, value }).then(value => decryptRaw({ prf, value })), value);
+ });
+});
+
+suite('encryptRaw', { concurrency: true }, () => {
+ test('throws on invalid version', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(encryptRaw({ version: 'INVALID', prf, value }));
+ });
+ test('throws on invalid prf', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(encryptRaw({ version, prf: 'INVALID', value }));
+ });
+ test('throws on invalid value', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(encryptRaw({ version, prf, value: {} }));
+ });
+});
+
+suite('decryptRaw', { concurrency: true }, () => {
+ const value = 'envpass:v1:AAA';
+ test('throws on invalid prf', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(decryptRaw({ prf: 'INVALID', value }));
+ });
+ test('throws on invalid value', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(decryptRaw({ prf, value: {} }));
+ });
+ test('throws on invalid magic in value', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(decryptRaw({ prf, value: 'INVALID:v1:AAA' }));
+ });
+ test('throws on invalid version in value', { concurrency: true }, () => {
+ // @ts-expect-error
+ rejects(decryptRaw({ prf, value: 'envpass:INVALID:AAA' }));
+ });
+ test('throws on invalid (non-base64) string in value', { concurrency: true }, () => {
+ rejects(decryptRaw({ prf, value: 'envpass:v1:🏴☠️' }));
+ });
+});
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { deepStrictEqual } from 'node:assert/strict';
+import { chdir, cwd } from 'node:process';
+
+import { encrypt } from '#src/index.js';
+import { setupPlaywright, mockOpen, assertConsole, setupFolder, fileEqual, fileNotEqual } from '#test/setup.js';
+
+suite('import @ardislu/envpass/decrypt', () => {
+ test('it works', async (t) => {
+ const env = 'secret=some_secret_value';
+ const { page } = await setupPlaywright(t);
+ const { folder, envFile } = await setupFolder(t, env);
+ mockOpen(t, page);
+ assertConsole(t, { log: 1 }); // Decrypt should be silent, so only 1 log
+
+ const oldDir = cwd();
+ chdir(folder);
+
+ // Verifies .env is encrypted
+ await fileEqual(envFile, env);
+ await encrypt();
+ await fileNotEqual(envFile, env);
+
+ deepStrictEqual(process.env.secret, undefined);
+ await import('#src/decrypt.js');
+ deepStrictEqual(process.env.secret, 'some_secret_value'); // Successfully decrypted and injected in process
+ await fileNotEqual(envFile, env); // .env is NOT decrypted
+
+ chdir(oldDir); // Release lock on temp folder to allow teardown
+ });
+});
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { deepStrictEqual, match, ok, rejects } from 'node:assert/strict';
+
+import { WINDOW_EXPIRATION_DURATION } from '#src/getPrf.js';
+import { setupPlaywright, setupServer } from '#test/setup.js';
+
+suite('getPrf.js (server)', () => {
+ test('validates challenge query parameter on GET', async (t) => {
+ const { url } = await setupServer(t);
+ const { origin } = new URL(url);
+
+ deepStrictEqual((await fetch(origin)).status, 401); // No challenge
+ deepStrictEqual((await fetch(`${origin}?challenge=INVALID`)).status, 401); // Bad challenge
+ deepStrictEqual((await fetch(url)).status, 200); // Good
+ });
+ test('validates payload on POST', async (t) => {
+ const { url, prf: prfActual } = await setupServer(t);
+ const { origin, searchParams } = new URL(url);
+ const challenge = searchParams.get('challenge');
+ const prf = 'deadbeef000000000000000000000000000000000000000000000000cafebabe';
+
+ deepStrictEqual((await fetch(origin, { method: 'POST', body: JSON.stringify({ prf }) })).status, 401); // No challenge
+ deepStrictEqual((await fetch(origin, { method: 'POST', body: JSON.stringify({ challenge: 'INVALID', prf }) })).status, 401); // Bad challenge
+ deepStrictEqual((await fetch(origin, { method: 'POST', body: JSON.stringify({ challenge }) })).status, 400); // No prf
+ deepStrictEqual((await fetch(origin, { method: 'POST', body: JSON.stringify({ challenge, prf: 'INVALID' }) })).status, 400); // Bad prf
+ deepStrictEqual((await fetch(origin, { method: 'POST', body: JSON.stringify({ challenge, prf }) })).status, 200); // Good
+ deepStrictEqual(await prfActual, Uint8Array.fromHex(prf));
+ });
+ test('window expiration works', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] }); // MUST be called BEFORE setupServer()
+ const { url } = await setupServer(t);
+
+ deepStrictEqual((await fetch(url)).status, 200);
+ t.mock.timers.tick(WINDOW_EXPIRATION_DURATION);
+ rejects(fetch(url), { name: 'TypeError', message: 'fetch failed' });
+ });
+ test('abort works', async (t) => {
+ const { url, controller } = await setupServer(t);
+
+ deepStrictEqual((await fetch(url)).status, 200);
+ controller.abort();
+ rejects(fetch(url), { name: 'TypeError', message: 'fetch failed' });
+ });
+});
+
+suite('getPrf.html (client)', () => {
+ test('basic page and styles are present', async (t) => {
+ const { setAutomaticSignIn, page } = await setupPlaywright(t);
+ const { url } = await setupServer(t);
+
+ await setAutomaticSignIn(false);
+ await page.goto(url);
+
+ // Basic instructions, title, and button are present
+ match(await page.locator('p').textContent() ?? '', /^Create or select a passkey/);
+ deepStrictEqual(await page.title(), 'envpass'); // Fails if it is the first test, but not if it comes second. Playwright bug?
+ deepStrictEqual(await page.getByRole('button').textContent(), 'click here');
+
+ // Light and dark themes work
+ await page.emulateMedia({ colorScheme: 'light' });
+ deepStrictEqual(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), 'rgb(217, 227, 242)');
+ await page.emulateMedia({ colorScheme: 'dark' });
+ deepStrictEqual(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), 'rgb(31, 47, 71)');
+ });
+ test('window expiration works', async (t) => {
+ const { setAutomaticSignIn, page } = await setupPlaywright(t);
+ const { url } = await setupServer(t);
+
+ await setAutomaticSignIn(false);
+
+ await page.clock.install();
+ await page.goto(url);
+
+ match(await page.locator('p').textContent() ?? '', /^Create or select a passkey/);
+ await page.clock.runFor(WINDOW_EXPIRATION_DURATION);
+ match(await page.locator('p').textContent() ?? '', /^This window has expired/);
+ });
+ test('create passkey on page load works', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const { url, prf } = await setupServer(t);
+ await page.goto(url);
+ ok(await prf !== null);
+ });
+ test('create passkey on button click works', async (t) => {
+ const { setAutomaticSignIn, page } = await setupPlaywright(t);
+ const { url, prf } = await setupServer(t);
+ await setAutomaticSignIn(false);
+ await page.goto(url);
+ await setAutomaticSignIn(true);
+ await page.getByRole('button').click();
+ ok(await prf !== null);
+ });
+ test('get passkey on page load works', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const server1 = await setupServer(t);
+ const server2 = await setupServer(t);
+
+ await page.goto(server1.url); // Creates a new passkey
+ const prf1 = await server1.prf;
+
+ await page.goto(server2.url); // Gets the existing passkey
+ const prf2 = await server2.prf;
+
+ deepStrictEqual(prf1, prf2);
+ });
+ test('get passkey on button click works', async (t) => {
+ const { setAutomaticSignIn, page } = await setupPlaywright(t);
+ const server1 = await setupServer(t);
+ const server2 = await setupServer(t);
+
+ await page.goto(server1.url);
+ const prf1 = await server1.prf;
+
+ await setAutomaticSignIn(false);
+ await page.goto(server2.url);
+ await setAutomaticSignIn(true);
+ await page.getByRole('button').click();
+ const prf2 = await server2.prf;
+
+ deepStrictEqual(prf1, prf2);
+ });
+ test('canceling in-flight request works', async (t) => {
+ const { setAutomaticSignIn, page } = await setupPlaywright(t);
+ const { url, prf } = await setupServer(t);
+
+ await setAutomaticSignIn(false);
+ await page.goto(url);
+
+ // Will break if more than 3 clicks. I believe there is some internal queue within Playwright or CDP
+ // that is getting clogged and causing the breakage. The purpose of this test is just to confirm the
+ // last request wins, so it is not really necessary to fix this clogged requests issue.
+ for (let i = 0; i < 3; i++) {
+ await page.getByRole('button').click();
+ // Block until next event loop turn to properly flush the aborted broadcast promise
+ await page.evaluate(() => new Promise(r => setTimeout(r, 0)));
+ }
+
+ await setAutomaticSignIn(true);
+ await page.getByRole('button').click();
+
+ ok(await prf !== null);
+ });
+ test('window expiration timeout is cleared after successful broadcast', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const { url } = await setupServer(t);
+ await page.clock.install();
+ await page.goto(url);
+
+ match(await page.locator('p').textContent() ?? '', /^Successfully sent passkey/);
+ await page.clock.runFor(WINDOW_EXPIRATION_DURATION);
+ match(await page.locator('p').textContent() ?? '', /^Successfully sent passkey/);
+ });
+});
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { readFile } from 'node:fs/promises';
+
+import { encrypt, decrypt } from '#src/index.js';
+import { setupPlaywright, setupEnv, mockOpen, assertConsole, fileEqual, fileNotEqual } from '#test/setup.js';
+
+/** Standard value of the .env file used for most setups. */
+const STD_ENV = 'A=123\nB=456\nC=789\n';
+
+suite('e2e', () => {
+ test('encrypt and decrypt .env', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, STD_ENV);
+ mockOpen(t, page);
+ assertConsole(t, { log: 2 });
+
+ await fileEqual(envFile, STD_ENV);
+ await encrypt({ inFile: envFile });
+ await fileNotEqual(envFile, STD_ENV);
+ await decrypt({ inFile: envFile });
+ await fileEqual(envFile, STD_ENV);
+ });
+ test('encrypt and decrypt .env silently', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, STD_ENV);
+ mockOpen(t, page);
+ assertConsole(t, { log: 0 });
+
+ await fileEqual(envFile, STD_ENV);
+ await encrypt({ inFile: envFile, silent: true });
+ await fileNotEqual(envFile, STD_ENV);
+ await decrypt({ inFile: envFile, silent: true });
+ await fileEqual(envFile, STD_ENV);
+ });
+ test('does nothing to empty .env file', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, '');
+ mockOpen(t, page);
+ assertConsole(t, { log: 2 });
+
+ await fileEqual(envFile, '');
+ await encrypt({ inFile: envFile });
+ await fileEqual(envFile, '');
+ await decrypt({ inFile: envFile });
+ await fileEqual(envFile, '');
+ });
+ test('does nothing to empty .env file silently', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, '');
+ mockOpen(t, page);
+ assertConsole(t, { log: 0 });
+
+ await fileEqual(envFile, '');
+ await encrypt({ inFile: envFile, silent: true });
+ await fileEqual(envFile, '');
+ await decrypt({ inFile: envFile, silent: true });
+ await fileEqual(envFile, '');
+ });
+ test('ignores variables already encrypted', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, STD_ENV);
+ mockOpen(t, page);
+ assertConsole(t, { log: 3 });
+
+ await fileEqual(envFile, STD_ENV);
+ await encrypt({ inFile: envFile });
+
+ const newContent = await readFile(envFile, { encoding: 'utf8' });
+ await encrypt({ inFile: envFile });
+ await fileEqual(envFile, newContent);
+
+ await decrypt({ inFile: envFile });;
+ await fileEqual(envFile, STD_ENV);
+ });
+ test('ignores variables already decrypted', async (t) => {
+ const { page } = await setupPlaywright(t);
+ const envFile = await setupEnv(t, STD_ENV);
+ mockOpen(t, page);
+ assertConsole(t, { log: 1 });
+
+ await fileEqual(envFile, STD_ENV);
+ await decrypt({ inFile: envFile });
+ await fileEqual(envFile, STD_ENV);
+ });
+});
--- /dev/null
+import { tmpdir } from 'node:os';
+import { open, rm, mkdir, readFile } from 'node:fs/promises';
+import { normalize } from 'node:path';
+import { deepStrictEqual, ok } from 'node:assert/strict';
+/** @import { TestContext } from 'node:test'; */
+
+import { chromium } from 'playwright-core';
+/** @import { Page } from 'playwright-core'; */
+
+import { getPrf, BROWSER } from '#src/getPrf.js';
+/** @import { GetPrfResult } from '#src/getPrf.js'; */
+
+/**
+ * @typedef {Object} SetupPlaywrightResult
+ * @property {Page} page Playwright `Page` instance to control the browser.
+ * @property {(enabled:boolean)=>Promise<void>} setAutomaticSignIn Configure if the browser will
+ * automatically sign in with passkey when prompted.
+ */
+
+/**
+ * Setup Playwright to test passkeys, with appropriate teardown hook.
+ * @param {TestContext} t Test context to add `after` hook to.
+ * @returns {Promise<SetupPlaywrightResult>}
+ */
+export async function setupPlaywright(t) {
+ const browser = await chromium.launch();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const cdp = await context.newCDPSession(page);
+ await cdp.send('WebAuthn.enable');
+ const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ hasPrf: true
+ }
+ });
+ const setAutomaticSignIn = async (/** @type {boolean}*/enabled) => {
+ await cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled });
+ };
+ t.after(async () => await browser.close());
+ return { page, setAutomaticSignIn };
+}
+
+/**
+ * @typedef {Object} SetupServerResult
+ * @property {AbortController} controller `AbortController` for the `AbortSignal` which was passed to
+ * the server. Call `controller.abort()` to close the server.
+ */
+
+/**
+ * Setup an HTTP server to orchestrate a passkey flow, with appropriate teardown hook.
+ * @param {TestContext} t Test context to add `after` hook to.
+ * @returns {Promise<Pick<GetPrfResult,'url'|'prf'>&SetupServerResult>}>}
+ */
+export async function setupServer(t) {
+ const controller = new AbortController();
+ const { url, prf } = await getPrf({ autoOpen: false, signal: controller.signal });
+ t.after(() => controller.abort());
+ return { controller, url, prf };
+}
+
+/**
+ * Create a temporary .env file for testing, with the appropriate teardown hook.
+ * @param {TestContext} t Test context to add `after` hook to.
+ * @param {string} [env] Content to write to the .env file. The default value is `''` (empty file).
+ * @returns {Promise<string>} Path to the .env file.
+ */
+export async function setupEnv(t, env = '') {
+ const path = normalize(`${tmpdir()}/envpass_test_${crypto.randomUUID()}.tmp`);
+ const handle = await open(path, 'a+');
+ await handle.writeFile(env);
+ await handle.close();
+ t.after(async () => { await rm(path); })
+ return path;
+}
+
+/**
+ * @typedef {Object} SetupFolderResult
+ * @property {string} folder Path to the temporary folder.
+ * @property {string} envFile Path to the `.env` file within the temporary folder.
+ */
+
+/**
+ * Create a temporary folder with a `.env` file for testing. Includes appropriate teardown hook for the folder.
+ * @param {TestContext} t Test context to add `after` hook to.
+ * @param {string} [env] Content to write to the .env file. The default value is `''` (empty file).
+ * @returns {Promise<SetupFolderResult>} Paths to the temporary folder and `.env` file.
+ */
+export async function setupFolder(t, env = '') {
+ const folder = normalize(`${tmpdir()}/envpass_test_${crypto.randomUUID()}`);
+ await mkdir(folder, { recursive: true });
+
+ const envFile = normalize(`${folder}/.env`);
+ const handle = await open(envFile, 'a+');
+ await handle.writeFile(env);
+ await handle.close();
+
+ t.after(async () => { await rm(folder, { recursive: true, force: true }); })
+ return { folder, envFile };
+}
+
+/**
+ * Intercept the `open` function to open a Playwright browser to the page instead of the user's own browser.
+ * @param {TestContext} t Test context to mock the `open` function for.
+ * @param {Page} page Playwright `Page` to use to navigate to the URL instead of the user's browser.
+ */
+
+export function mockOpen(t, page) {
+ /** @param {string} url */
+ const interceptor = url => { page.goto(url); }
+ t.mock.method(BROWSER, 'open', interceptor);
+}
+
+/**
+ * @typedef AssertConsoleCounts The total number of times `console.log`, `console.info`, `console.warn`, and
+ * `console.error` are expected to be called.
+ * @property {number} [log] The number of times `console.log` is expected to be called. The default value is 0.
+ * @property {number} [info] The number of times `console.info` is expected to be called. The default value is 0.
+ * @property {number} [warn] The number of times `console.warn` is expected to be called. The default value is 0.
+ * @property {number} [error] The number of times `console.error` is expected to be called. The default value is 0.
+ */
+
+/**
+ * Silences console functions during a test and asserts a given number of calls were made during the test.
+ *
+ * Console will only be silenced AFTER this function is called, and only until the test ends. Call this
+ * function early in the test to avoid unexpected console calls.
+ * @param {TestContext} t Test context to mock the `console` functions for.
+ * @param {AssertConsoleCounts} [expected] Counts for the total number of times console functions are expected
+ * to be called.
+ */
+export function assertConsole(t, expected = {}) {
+ const actual = { log: 0, info: 0, warn: 0, error: 0 };
+ expected = { ...actual, ...expected };
+
+ for (const m of /** @type {Array<keyof typeof actual>} */(Object.keys(actual))) {
+ t.mock.method(console, m, () => actual[m]++);
+ t.after(() => deepStrictEqual(actual[m], expected[m], `expected ${expected[m]} console.${m} calls, got ${actual[m]}`));
+ }
+}
+
+/**
+ * Assert a text file's contents are equal to expected contents.
+ * @param {string} path Path to a text file.
+ * @param {string} expected Expected contents of the text file.
+ */
+export async function fileEqual(path, expected) {
+ const actual = await readFile(path, { encoding: 'utf8' });
+ deepStrictEqual(actual, expected);
+}
+
+/**
+ * Assert a text file's contents are NOT equal to expected contents.
+ * @param {string} path Path to a text file.
+ * @param {string} expected Contents which the text file should NOT be equal to.
+ */
+export async function fileNotEqual(path, expected) {
+ const actual = await readFile(path, { encoding: 'utf8' });
+ ok(actual !== expected);
+}
\ No newline at end of file
--- /dev/null
+import { suite, test } from 'node:test';
+import { ok, deepStrictEqual, throws } from 'node:assert/strict';
+
+import { toEnvString, parsePrf, parseEnvpassEncrypted, InputError } from '#src/utils.js';
+import { parseEnv } from 'node:util';
+
+// Useful reference: https://dotenvx.com/spec/
+suite('toEnvString', { concurrency: true }, () => {
+ const vectors = [
+ { name: 'empty object', obj: {}, str: '' },
+ { name: 'simple object', obj: { a: '1', b: '2', c: '3' }, str: 'a=1\nb=2\nc=3\n' },
+ ];
+ for (const { name, obj, str } of vectors) {
+ test(`converts: ${name}`, { concurrency: true }, () => {
+ deepStrictEqual(toEnvString(obj), str);
+ });
+ test(`inverses parseEnv for: ${name}`, { concurrency: true }, () => {
+ deepStrictEqual(toEnvString(parseEnv(str)), str);
+ });
+ test(`is inverse of parseEnv for: ${name}`, { concurrency: true }, () => {
+ const env = Object.create(null);
+ Object.assign(env, obj);
+ deepStrictEqual(parseEnv(toEnvString(env)), env);
+ });
+ }
+});
+
+suite('parsePrf', { concurrency: true }, () => {
+ const rejects = [
+ { name: 'rejects empty', input: '' },
+ { name: 'rejects 1 character', input: 'a' },
+ { name: 'rejects 2 characters', input: 'aa' },
+ { name: 'rejects 62 characters', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
+ { name: 'rejects 66 characters', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
+ { name: 'rejects non-hex', input: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' },
+ { name: 'rejects non-hex with "0x" prefix', input: '0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' },
+ ];
+ for (const { name, input } of rejects) {
+ test(name, { concurrency: true }, () => {
+ deepStrictEqual(parsePrf(input), null);
+ });
+ }
+ const accepts = [
+ { name: 'accepts all lowercase', input: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
+ { name: 'accepts all uppercase', input: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
+ { name: 'accepts mixed case', input: 'AaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
+ { name: 'accepts with "0x" prefix', input: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
+ { name: 'accepts with "0X" prefix', input: '0XAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' },
+ ];
+ for (const { name, input } of accepts) {
+ test(name, { concurrency: true }, () => {
+ deepStrictEqual(parsePrf(input), Uint8Array.fromHex(input.replace(/0x/i, '')));
+ });
+ }
+});
+
+suite('parseEnvpassEncrypted', { concurrency: true }, () => {
+ const rejects = [
+ { name: 'rejects empty', input: '' },
+ { name: 'rejects empty with colons', input: '::' },
+ { name: 'rejects missing magic', input: ':v1:rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ { name: 'rejects missing version', input: 'envpass::rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ { name: 'rejects missing string', input: 'envpass:v1:' },
+ { name: 'rejects invalid magic', input: 'INVALID:v1:rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ { name: 'rejects invalid version', input: 'envpass:INVALID:rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ { name: 'rejects leading whitespace', input: ' envpass:v1:rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ ];
+ for (const { name, input } of rejects) {
+ test(name, { concurrency: true }, () => {
+ deepStrictEqual(parseEnvpassEncrypted(input), null);
+ });
+ }
+ const accepts = [
+ { name: 'accepts envpass encrypted string', input: 'envpass:v1:rXAhBNXZFsLl8EtZm3GHAZttZyLYP3j97Yy4qly53wBodRyyKYi8PlxjOlggc7mVLA/HoME4QSKdhuEs3C4UKvo0J3ZAV+JbrzfZbsBJ3tvvG4/UEVxB1+0IPoUyjhoO' },
+ ];
+ for (const { name, input } of accepts) {
+ test(name, { concurrency: true }, () => {
+ deepStrictEqual(parseEnvpassEncrypted(input), input);
+ });
+ }
+});
+
+suite('InputError', { concurrency: true }, () => {
+ test('is throwable', { concurrency: true }, () => {
+ throws(() => { throw new InputError(); }, InputError);
+ });
+ test('inherits Error', { concurrency: true }, () => {
+ ok(new InputError() instanceof Error);
+ });
+ test('sets name to "InputError"', { concurrency: true }, () => {
+ deepStrictEqual(new InputError().name, 'InputError');
+ });
+ test('accepts string message', { concurrency: true }, () => {
+ const msg = 'TEST';
+ deepStrictEqual(new InputError(msg).message, msg);
+ });
+});
\ No newline at end of file
--- /dev/null
+{
+ "include": ["src/**/*"],
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./types",
+ "module": "NodeNext",
+ "target": "ESNext",
+ "lib": ["ESNext"],
+ "types": ["node"],
+ "allowJs": true,
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "strict": true
+ }
+}
\ No newline at end of file