micro-key-producer
Version:
Produces secure keys and passwords. Supports SSH, PGP, BLS, OTP, and many others
287 lines • 11.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.secureMask = exports.mask = exports.alphabet = exports.DATE = void 0;
exports.zip = zip;
exports.or = or;
exports.and = and;
exports.product = product;
exports.formatDuration = formatDuration;
exports.checkPassword = checkPassword;
exports.cardinalityBits = cardinalityBits;
/*! micro-key-producer - MIT License (c) 2024 Paul Miller (paulmillr.com) */
const utils_1 = require("@noble/curves/abstract/utils");
function zip(a, b) {
let res = [];
for (let i = 0; i < Math.max(a.length, b.length); i++)
res.push([a[i], b[i]]);
return res;
}
// set utils
function or(...sets) {
return sets.reduce((acc, i) => new Set([...acc, ...i]), new Set());
}
function and(...sets) {
return sets.reduce((acc, i) => new Set(Array.from(acc).filter((j) => i.has(j))));
}
function product(...sets) {
return sets.reduce((acc, i) => new Set(Array.from(acc)
.map((j) => Array.from(i).map((k) => j + k))
.flat()));
}
exports.DATE = { sec: 1000 };
exports.DATE.min = 60 * exports.DATE.sec;
exports.DATE.h = 60 * exports.DATE.min;
exports.DATE.d = 24 * exports.DATE.h;
exports.DATE.mo = 30 * exports.DATE.d;
exports.DATE.y = 365 * exports.DATE.mo;
function formatDuration(dur) {
if (Number.isNaN(dur))
return 'never';
if (dur > exports.DATE.y * 100)
return 'centuries';
let parts = [];
for (let [name, period] of Object.entries(exports.DATE).reverse()) {
if (dur < period)
continue;
let value = Math.floor(dur / period);
parts.push(`${value}${name}`);
dur -= value * period;
}
return parts.length > 0 ? parts.join(' ') : '0 sec';
}
// NOTE: all items inside alphabet size should have same size
exports.alphabet = {};
// Digits
exports.alphabet['1'] = new Set('0123456789');
// Symbols
exports.alphabet['@'] = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~');
// Vowels
exports.alphabet['v'] = new Set('aeiouy');
// Consonant
exports.alphabet['c'] = new Set('bcdfghjklmnpqrstvwxz');
// V+C
exports.alphabet['a'] = or(exports.alphabet['v'], exports.alphabet['c']);
// Uppercase variants
for (const v of 'vca')
exports.alphabet[v.toUpperCase()] = new Set(Array.from(exports.alphabet[v]).map((i) => i.toUpperCase()));
// uppercase+lowercase (letter?)
exports.alphabet['l'] = or(exports.alphabet['a'], exports.alphabet['A']);
// uppercase+lowercase+digits (alpha(N)umeric?)
exports.alphabet['n'] = or(exports.alphabet['l'], exports.alphabet['1']);
// uppercase+lowercase+digits+symbols
exports.alphabet['*'] = or(exports.alphabet['n'], exports.alphabet['@']);
const TEMPLATES = {
// Syllable (Consonant+vowel)
s: 'cv',
// uppercase consonant + vowel
S: 'Cv',
};
// Mask utils
function idx(arr, i) {
if (!Array.isArray(arr))
arr = Array.from(arr);
if (i < 0 || i >= arr.length)
throw new Error('Out of bounds index access');
return arr[i];
}
/**
* Check if password is correct for rules in design rationale.
*/
function checkPassword(pwd) {
if (pwd.length < 8)
return false;
const s = new Set(pwd);
for (const c of 'aA1@')
if (!and(s, exports.alphabet[c]).size)
return false;
return true;
}
/**
* Like base convertInt, but with variable size alphabet.
*/
function splitEntropy(lengths, entropy) {
let entropyLeft = (0, utils_1.bytesToNumberBE)(entropy);
let values = [];
for (const c of lengths) {
const sz = BigInt(c);
values.push(Number(entropyLeft % sz));
entropyLeft /= sz;
}
return { values, entropyLeft };
}
function cardinalityBits(cardinality) {
let i = 0;
for (let c = cardinality; c; i++, c >>= 1n)
;
return i - 1;
}
// Estimates
function guessTime(cardinality, perSec) {
return formatDuration((Number(cardinality) / perSec) * 1000);
}
function passwordScore(cardinality) {
const scores = [
[1e3 + 5, 'too guessable'],
[1e6 + 5, 'very guessable'],
[1e8 + 5, 'somewhat guessable'],
[1e10 + 5, 'safely unguessable'],
];
let res = 'very unguessable';
for (const [i, v] of scores) {
if (cardinality <= BigInt(i)) {
res = v;
break;
}
}
return res;
}
/**
* Estimate attack price for a password.
* @returns `{ luks, filevault2, macos, pbkdf2 }`
*/
function estimateAttack(cardinality) {
// Time estimates are not correct: we don't know how much hardware an attacker
// has, it is better to estimate price of an attack. We do napkin math of TCO
// (total cost of ownership) of a rig and calculate attack price based on it.
// Full price of single GPU with included price CPU/MB/PSU
// (but each card of rig takes only part of these costs)
// Based on: https://bitcoinmerch.com/products/ready-to-mine™-6-x-nvidia-rtx-3080-non-lhr-complete-mining-rig-assembled
const GPU_PRICE = 20500 / 6;
// Cost of 1s of GPU time, assuming card will be used at least for 2 years
const GPU_COST = GPU_PRICE / (2 * (exports.DATE.y / 1000));
// NOTE: you can probably sell rig at 30-50% of price after 2 years
// https://lambdalabs.com/blog/deep-learning-hardware-deep-dive-rtx-30xx/
const GPU_POWER = 320; // RTX 3080 – 320W (28% more than RTX 2080 Ti)
const GPU_POWER_RIG = (80 + 280 + 6 * GPU_POWER) / 6; // Assuming 6x cards per rig +CPU+MB
// 0.12$ per kWh https://www.techarp.com/computer/cybercafe-rtx-3080-cryptomining/
const KWH_PRICE = 0.12;
// +33% for cooling needs (AC)
const KWH_COOLING = KWH_PRICE + KWH_PRICE * 0.33;
// Price of kw per hour -> price of watt per sec
const WS = KWH_COOLING / 60 / 1000;
const ENERGY_COST = GPU_POWER_RIG * WS;
const TOTAL_GPU_COST = ENERGY_COST + GPU_COST;
const calcCost = (hashes) => Number(cardinality / BigInt(hashes)) * TOTAL_GPU_COST;
return {
// Score/guesses based on zxcvbn, it is pretty bad model, but will be ok for now
score: passwordScore(cardinality),
guesses: {
online_throttling: guessTime(cardinality, 100 / (exports.DATE.h / 1000)), // 100 per hour
online: guessTime(cardinality, 10), // 10 per sec
slow: guessTime(cardinality, 10000),
fast: guessTime(cardinality, 10000000000),
},
// Password is assumed salted.
// Non-salted passwords allow multi-target attacks which significantly reduces costs.
// Values taken from hashcat 6.1.1 on RTX 3080
// https://gist.github.com/Chick3nman/bb22b28ec4ddec0cb5f59df97c994db4
costs: {
luks: calcCost(22779), // linux FDE
filevault2: calcCost(151300), // macOS FDE
macos: calcCost(1019200), // macOS v10.8+ (PBKDF2-SHA512), password?
pbkdf2: calcCost(3029200), // PBKDF2-HMAC-SHA256
},
};
}
class Mask {
constructor(mask) {
mask = mask
.split('')
.map((i) => TEMPLATES[i] || i)
.join('');
this.chars = mask.split('');
this.length = this.chars.length;
this.sets = this.chars.map((i) => exports.alphabet[i] || new Set([i]));
this.lengths = this.sets.map((i) => i.size);
this.cardinality = this.sets.reduce((acc, i) => acc * BigInt(i.size), 1n);
this.entropy = cardinalityBits(this.cardinality);
}
apply(entropy) {
// There should be at least x2 more bits in entropy than required for mask to avoid modulo bias, since
// it basically (% this.cardinality)
if (this.cardinality >= 2n ** BigInt((8 * entropy.length) / 2))
throw new Error('Not enough entropy');
const { entropyLeft, values } = splitEntropy(this.lengths, entropy);
const password = zip(this.sets, values)
.map(([s, v]) => idx(s, v))
.join('');
return { password, entropyLeft };
}
inverse({ password, entropyLeft }) {
const values = zip(this.sets, password.split('')).map(([s, c]) => Array.from(s).indexOf(c));
const num = zip(this.sets, values).reduceRight((acc, [s, v]) => acc * BigInt(s.size) + BigInt(v), 0n);
return (0, utils_1.numberToVarBytesBE)(entropyLeft * this.cardinality + num);
}
estimate() {
return estimateAttack(this.cardinality);
}
}
const mask = (mask) => new Mask(mask);
exports.mask = mask;
/*
'Safari Keychain Secure Password'-like password:
- good because of user-base, no fignerprinting, also passes all requirements and still readable
- mask: 'cvccvc-cvccvc-cvccvc' (20 chars, 18 non-constant chars)
- digit inserted in first or last position of group: '1cvccv' or 'cvcvc1'
- only one non-numeric char is upper-cased
- uses dashes to bypass special symbol requirement, but still copyable (some other symbols will break select on click)
- hard to verify entropy in tests :(
*/
const secureMasks = [];
for (let upper = 0; upper < 17; upper++) {
for (let digitPos = 0; digitPos < 3; digitPos++) {
for (let digitLeft = 0; digitLeft < 2; digitLeft++) {
const groups = ['cvccvc', 'cvccvc', 'cvccvc'];
groups[digitPos] = digitLeft ? '1cvcvc' : 'cvccv1';
const mask = groups.join('-');
let res;
for (let i = 0, sI = 0; i < mask.length; i++) {
const chr = mask[i];
if (!['c', 'v'].includes(chr))
continue;
if (sI === upper)
res = mask.slice(0, i) + chr.toUpperCase() + mask.slice(i + 1);
sI++;
}
if (!res)
throw new Error('Cannot find uppercase syllable index');
secureMasks.push(res);
}
}
}
exports.secureMask = (() => {
const size = BigInt(secureMasks.length);
const cardinality = (0, exports.mask)(secureMasks[0]).cardinality * size;
return {
length: 20,
cardinality,
entropy: cardinalityBits(cardinality),
estimate: () => estimateAttack(cardinality),
apply: (entropy) => {
let entropyLeft = (0, utils_1.bytesToNumberBE)(entropy);
const idx = Number(entropyLeft % size);
return (0, exports.mask)(secureMasks[idx]).apply((0, utils_1.numberToVarBytesBE)(entropyLeft / size));
},
inverse(res) {
const chars = res.password.split('');
const maskStr = chars
.map((i) => {
const possibleValues = Object.entries(exports.alphabet)
.filter(([c, _]) => ['c', 'v', 'C', 'V', '1'].includes(c))
.map(([c, v]) => [c, and(v, new Set([i]))])
.filter(([_, v]) => v.size > 0);
if (possibleValues.length > 1)
throw new Error('Too much possible values, cannot detect mask.');
return possibleValues.length ? possibleValues[0][0] : i;
})
.join('');
const idx = secureMasks.indexOf(maskStr);
if (idx < 0)
throw new Error('Unknown mask');
const entropy = (0, exports.mask)(secureMasks[idx]).inverse(res);
const entropyNum = (0, utils_1.bytesToNumberBE)(entropy);
return (0, utils_1.numberToVarBytesBE)(entropyNum * size + BigInt(idx));
},
};
})();
//# sourceMappingURL=password.js.map