micro-key-producer
Version:
Produces secure passwords & keys for WebCrypto, SSH, PGP, SLIP10, OTP and many others
223 lines (215 loc) • 8.28 kB
JavaScript
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();