UNPKG

micro-key-producer

Version:

Produces secure passwords & keys for WebCrypto, SSH, PGP, SLIP10, OTP and many others

223 lines (215 loc) 8.28 kB
#!/usr/bin/env node import { concatBytes } from '@noble/hashes/utils.js'; import { closeSync, constants, openSync, readFileSync } from 'node:fs'; import { dirname as pdirname, join as pjoin } from 'node:path'; import { ReadStream, WriteStream } from 'node:tty'; import { fileURLToPath } from 'node:url'; import { parsePrivateKey, signDetached, verifyDetached } from '../pgp.js'; /* * Usage: * - GPGSIGNKP_KEY=<path> -- path to private key (with base64 armor) * - GPGSIGNKP_HIDE_PASSWORD=1 -- don't print '*' when entering password * * Status: * - Signing commits (GPG verifies signatures generated by this script). * - Basic commit verification: checks if a commit was signed by our current key. * - Supports keys with and without passwords generated by `pgp.getKeys()` and `gpg` (basic cases). * - Generates the exact same commits as `gpg` with the same key (when mocking timestamps). * * Issues: * - Not all arguments are supported (e.g., `-bsau`): not a major problem, as Git hardcodes most arguments * - The script is fragile: * - !!! Avoid using `console.log` or `console.error` in this code. * - Invoked via `exec` in Git: stdin provides the commit, and signature/info must be * written to stdout unconditionally. * - Git’s `--status-fd` flag expects GPG-like status info on the status FD (usually stderr). * If not provided correctly, verification fails even with valid signatures. * - User prompts use `/dev/tty`, which is fragile given Git’s manipulation of stdin/stdout. * * Info: * - sign: "gpg --status-fd=2 -bsau 21B287CDD55ACB9F" * - verify: "gpg --keyid-format=long --status-fd=1 --verify ./commit.raw -" * - git code: https://github.com/git/git/blob/6f84262c44a89851c3ae5a6e4c1a9d06b2068d75/gpg-interface.c#L337 */ const __filename = fileURLToPath(import.meta.url); const __dirname = pdirname(__filename); const NL = '\n'; async function parseArgs(envPrefix) { // Args/opts const argv = process.argv.slice(1); const args = []; const opts = {}; for (let i = 0; i < argv.length; i++) { const curr = argv[i]; if (curr.startsWith('--')) { let key = curr.substring(2); let value; if (key.includes('=')) { [key, value] = key.split('=', 2); } else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) { value = argv[++i]; } else { value = true; } opts[key] = value; } else if (curr.startsWith('-')) { args.push(curr); } else { args.push(curr); } } // Env const env = {}; for (const i in process.env) { if (!i.startsWith(envPrefix)) continue; env[i.slice(envPrefix.length)] = process.env[i]; } // Stdin const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const stdin = concatBytes(...chunks); return { args, argv, stdin, opts, env }; } // NOTE: for debug only! // const log = (o) => appendFileSync(pjoin(__dirname, './log.json'), JSON.stringify(o) + '\n'); /** * interceptTTY will work only on Linux or macOS: there is no /dev/tty on windows. * STDIN is used for commit input, STDOUT is used for signature output. * We intercept current TTY and ask for password directly in there. */ function interceptTTY() { const { O_RDONLY, O_NOCTTY, O_WRONLY } = constants; const isWin = process.platform === 'win32'; // Untested! const inputFd = openSync(isWin ? 'CONIN$' : '/dev/tty', O_RDONLY + O_NOCTTY); const outputFd = openSync(isWin ? 'CONOUT$' : '/dev/tty', O_WRONLY); return { input: new ReadStream(inputFd), inputFd, output: new WriteStream(outputFd), outputFd, }; } function ask(prompt = 'Password: ', opts = {}) { const streams = interceptTTY(); // Simpler if FDs are self-managed const required = true; // make optional? streams.input.setEncoding('utf8'); return new Promise((resolve, reject) => { let input = ''; const stop = () => { streams.output.write(NL); streams.input.removeListener('data', process); streams.input.setRawMode(false); streams.input.destroy(); streams.output.destroy(); closeSync(streams.inputFd); closeSync(streams.outputFd); }; const ES_START = 'START'; const ES_NORMAL = 'NORMAL'; const ES_PROCESS = 'PROCESS'; let escapeState = ES_NORMAL; // It sends chunks: // - copy-paste: sends whole pasted string // - arrows: send whole escape sequence // Will break if just parse chunks const process = (chunk) => { for (const c of chunk) { const code = c.charCodeAt(0); // We need to parse sequences, otherwise stuff like // arrows will do weird things & corrupt passwords if (escapeState === ES_START) { if (c === '[' || c === 'O') escapeState = ES_PROCESS; else escapeState = ES_NORMAL; continue; } if (escapeState === ES_PROCESS) { if (code >= 0x40 && code <= 0x7e) escapeState = ES_NORMAL; continue; } if (c === '\x1B') { escapeState = ES_START; continue; } // EOT, Enter (CR), Enter (LF) if (['\u0004', '\r', NL].includes(c)) { if (required && input.length === 0) continue; const value = input.replace(/\r$/, ''); // make sure nothing will change value stop(); resolve(value); return; } // Ctrl-C if (c === '\u0003') { stop(); reject(new Error('ctrl-c')); return; } // Backspace & delete if (code === 127 || code === 8) { if (input.length === 0) return; input = input.slice(0, -1); streams.output.write('\x1B[1D\x1B[K'); continue; } // Ignore control stuff if (c.length === 1 && code < 32 && code !== 9) continue; input += c; streams.output.write(opts.mask ? opts.mask(c) : c); } }; streams.input.on('data', process); streams.output.write(`\x1B[2K\x1B[G${prompt}: `); streams.input.setRawMode(true); streams.input.resume(); }); } // More secure, but annoying const askPasswordHidden = (prompt) => ask(prompt, { mask: (_c) => '' }); const askPasswordMasked = (prompt) => ask(prompt, { mask: (c) => '*'.repeat(c.length) }); const resolvePath = (s) => (s.startsWith('/') ? s : pjoin(__dirname, s)); async function main() { const { stdout, stderr } = process; let status = stderr; try { const opts = await parseArgs('GPGKP_'); if (opts.opts['status-fd']) status = new WriteStream(+opts.opts['status-fd']); // log(opts); const askPassword = !!+opts.env.HIDE_PASSWORD ? askPasswordHidden : askPasswordMasked; const envk = opts.env.KEY; let privateKey = envk ? readFileSync(resolvePath(envk), 'utf8') : ''; if (!privateKey) throw new Error('Provide PGP key via GPGKP_KEY=<path>'); // Parse const parsed = await parsePrivateKey(privateKey, () => askPassword('GPG Private Key Password')); const fp = parsed.fingerprint; const fpup = fp.toUpperCase(); // We also support basic verification here, otherwise 'verify-commit' will fail. // This only checks the commit was signed by *the* private key: there are no other keys! if (opts.opts.verify) { const signature = readFileSync(opts.opts.verify, 'utf8'); const commit = opts.stdin; if (!verifyDetached(parsed.publicKey, signature, commit)) throw new Error('wrong signature'); status.write( `[GNUPG:] KEY_CONSIDERED ${fpup} 0 [GNUPG:] GOODSIG ${parsed.keyId.toUpperCase()} John Doe <example@example.com> [GNUPG:] VALIDSIG ${fpup} 1970-01-01 0 0 4 0 22 10 01 ${fpup} [GNUPG:] TRUST_UNDEFINED 0 pgp${NL}` ); process.exit(0); } // !!! 0 won't work here: timestamp should be after the commit const ts = Math.ceil(Date.now() / 1000); const signature = signDetached(parsed.privateKey, opts.stdin, fp, ts); status.write( `[GNUPG:] KEY_CONSIDERED ${fpup} 2 [GNUPG:] BEGIN_SIGNING H10 [GNUPG:] SIG_CREATED D 22 10 00 ${ts} ${fpup}${NL}` ); stdout.write(signature); process.exit(0); } catch (e) { // log({ error: e.toString() }); status.write(`[ERROR] ${e.toString()}${NL}`); process.exit(1); } } main();