diff --git a/bin/hash-password.mjs b/bin/hash-password.mjs index 1ddc488..9b0f068 100644 --- a/bin/hash-password.mjs +++ b/bin/hash-password.mjs @@ -1,17 +1,48 @@ #!/usr/bin/env node import crypto from "node:crypto"; -import readline from "node:readline"; const username = process.argv[2]; if (!username || !/^[a-zA-Z0-9._-]{1,64}$/.test(username)) { console.error("usage: bin/hash-password.mjs USERNAME"); process.exit(2); } -const terminal = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true }); -terminal.question("Password: ", (password) => { - terminal.close(); - if (password.length < 12) { console.error("password must be at least 12 characters"); process.exit(2); } - const salt = crypto.randomBytes(16); - const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); - console.log(JSON.stringify({ username, passwordHash: `scrypt$16384$8$1$${salt.toString("base64url")}$${hash.toString("base64url")}` })); -}); + +// Echoing the password would leave it in scrollback, shell history, and any +// captured terminal log, so the prompt reads raw keystrokes instead. +function readPassword(prompt) { + return new Promise((resolve, reject) => { + const input = process.stdin; + if (!input.isTTY) { + reject(new Error("stdin must be a terminal")); + return; + } + process.stderr.write(prompt); + input.setRawMode(true); + input.resume(); + input.setEncoding("utf8"); + let value = ""; + const settle = (error, result) => { + input.removeListener("data", onData); + input.setRawMode(false); + input.pause(); + process.stderr.write("\n"); + if (error) reject(error); + else resolve(result); + }; + const onData = (chunk) => { + for (const character of chunk) { + if (character === "\r" || character === "\n" || character === "\u0004") return settle(null, value); + if (character === "\u0003") return settle(new Error("cancelled")); + if (character === "\u007f" || character === "\b") value = value.slice(0, -1); + else if (character >= " ") value += character; + } + }; + input.on("data", onData); + }); +} + +const password = await readPassword("Password: "); +if (password.length < 12) { console.error("password must be at least 12 characters"); process.exit(2); } +const salt = crypto.randomBytes(16); +const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); +console.log(JSON.stringify({ username, passwordHash: `scrypt$16384$8$1$${salt.toString("base64url")}$${hash.toString("base64url")}` })); diff --git a/control-server/public/styles.css b/control-server/public/styles.css index 178840d..2b1bc92 100644 --- a/control-server/public/styles.css +++ b/control-server/public/styles.css @@ -1,5 +1,6 @@ :root { --ink:#171811; --paper:#e9e3d2; --acid:#d8ff3e; --rust:#bb3e24; --muted:#817d70; --line:#3a3b31; } * { box-sizing:border-box; } +[hidden] { display:none !important; } body { margin:0; min-height:100vh; background:var(--ink); color:var(--paper); font-family:"IBM Plex Mono","Courier New",monospace; } .grain { position:fixed; inset:0; opacity:.12; pointer-events:none; background-image:repeating-linear-gradient(0deg,transparent,transparent 3px,#fff 4px); mix-blend-mode:overlay; } .eyebrow { margin:0 0 .5rem; color:var(--acid); font-size:.72rem; letter-spacing:.2em; text-transform:uppercase; }