UNPKG

micro-key-producer

Version:

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

383 lines (357 loc) 13.7 kB
/*! micro-key-producer - MIT License (c) 2024 Paul Miller (paulmillr.com) */ /** * Allows to create secure passwords, using masks. * Supports iOS / macOS Safari Secure Password from Keychain. * Optional zxcvbn score for password bruteforce estimation. * @module */ import { bytesToNumberBE, numberToVarBytesBE } from '@noble/curves/utils.js'; function zip<A, B>(a: A[], b: B[]): [A, B][] { let res: [A, B][] = []; for (let i = 0; i < Math.max(a.length, b.length); i++) res.push([a[i], b[i]]); return res; } // set utils function or<T>(...sets: Set<T>[]): Set<T> { return sets.reduce((acc, i) => new Set([...acc, ...i]), new Set()); } function and<T>(...sets: Set<T>[]): Set<T> { return sets.reduce((acc, i) => new Set(Array.from(acc).filter((j) => i.has(j)))); } function product(...sets: Set<string>[]): Set<string> { return sets.reduce( (acc, i) => new Set( Array.from(acc) .map((j) => Array.from(i).map((k) => j + k)) .flat() ) ); } const DATE: Record<string, number> = { sec: 1000 }; DATE.min = 60 * DATE.sec; DATE.h = 60 * DATE.min; DATE.d = 24 * DATE.h; DATE.mo = 30 * DATE.d; DATE.y = 365 * DATE.mo; function formatDuration(dur: number): string { if (Number.isNaN(dur)) return 'never'; if (dur > DATE.y * 100) return 'centuries'; let parts = []; for (let [name, period] of Object.entries(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'; } /** Character classes used by password masks. */ // NOTE: all items inside alphabet size should have same size export const alphabet: Record<string, Set<string>> = {}; // Digits alphabet['1'] = new Set('0123456789'); // Symbols alphabet['@'] = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'); // Vowels alphabet['v'] = new Set('aeiouy'); // Consonant alphabet['c'] = new Set('bcdfghjklmnpqrstvwxz'); // V+C alphabet['a'] = or(alphabet['v'], alphabet['c']); // Uppercase variants for (const v of 'vca') alphabet[v.toUpperCase()] = new Set(Array.from(alphabet[v]).map((i: string) => i.toUpperCase())); // uppercase+lowercase (letter?) alphabet['l'] = or(alphabet['a'], alphabet['A']); // uppercase+lowercase+digits (alpha(N)umeric?) alphabet['n'] = or(alphabet['l'], alphabet['1']); // uppercase+lowercase+digits+symbols alphabet['*'] = or(alphabet['n'], alphabet['@']); const TEMPLATES: Record<string, string> = { // Syllable (Consonant+vowel) s: 'cv', // uppercase consonant + vowel S: 'Cv', }; // Mask utils function idx<T>(arr: Array<T> | Set<T>, i: number): T { 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]; } /** Low-level password mask helpers. */ export const utils = { zip: zip as typeof zip, or: or as typeof or, and: and as typeof and, product: product as typeof product, cardinalityBits: cardinalityBits as typeof cardinalityBits, formatDuration: formatDuration as typeof formatDuration, DATE: DATE as typeof DATE, }; /** * Check if password is correct for rules in design rationale. * @param pwd - Candidate password string. * @returns Whether the password satisfies the built-in strength rules. * @example * Validate that a candidate password covers the required character classes. * ```ts * import { checkPassword } from 'micro-key-producer/password.js'; * checkPassword('Aa1!aaaa'); * ``` */ export function checkPassword(pwd: string): boolean { if (pwd.length < 8) return false; const s = new Set(pwd); for (const c of 'aA1@') if (!and(s, alphabet[c]).size) return false; return true; } /** * Like base convertInt, but with variable size alphabet. */ function splitEntropy(lengths: number[], entropy: Uint8Array) { let entropyLeft = 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: bigint): number { let i = 0; for (let c = cardinality; c; i++, c >>= 1n); return i - 1; } // Estimates function guessTime(cardinality: bigint, perSec: number): string { return formatDuration((Number(cardinality) / perSec) * 1000); } function passwordScore(cardinality: bigint) { const scores: [number, string][] = [ [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; } /** Estimated password guessing effort. */ export type PassEstimate = { /** Human-readable strength label derived from the estimated search space. */ // Score/guesses based on zxcvbn, it is pretty bad model, but will be ok for now score: string; /** Time-to-guess estimates for a few attacker models. */ guesses: { /** Online attack with strict throttling such as account lockouts. */ online_throttling: string; /** Online attack without meaningful throttling. */ online: string; /** Slow offline attack. */ slow: string; /** Fast offline attack. */ fast: string; }; /** Approximate hardware cost of exhaustive attacks against several KDF targets. */ // 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: { /** Estimated attack cost against LUKS-style targets. */ luks: number; /** Estimated attack cost against FileVault 2. */ filevault2: number; /** Estimated attack cost against macOS PBKDF2-SHA512. */ macos: number; /** Estimated attack cost against PBKDF2-HMAC-SHA256. */ pbkdf2: number; }; }; /** * Estimate attack price for a password. * @returns `{ luks, filevault2, macos, pbkdf2 }` */ function estimateAttack(cardinality: bigint) { // 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 * (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) => 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 / (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 }, }; } type ApplyResult = { password: string; entropyLeft: bigint }; class Mask { private chars: string[]; private sets: Set<string>[]; private lengths: number[]; // sizes of sets readonly cardinality: bigint; readonly entropy: number; readonly length: number; constructor(mask: string) { mask = mask .split('') .map((i) => TEMPLATES[i] || i) .join(''); this.chars = mask.split(''); this.length = this.chars.length; this.sets = this.chars.map((i) => 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: Uint8Array): ApplyResult { // 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 }: ApplyResult): Uint8Array { 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 numberToVarBytesBE(entropyLeft * this.cardinality + num); } estimate(): PassEstimate { return estimateAttack(this.cardinality); } } /** * Compiles a password mask into an object that can apply or invert entropy. * @param mask - Password mask expression. * @returns Compiled password mask. * @example * Compile a password mask into an object that can apply or invert entropy. * ```ts * import { mask } from 'micro-key-producer/password.js'; * mask('cv1').apply(new Uint8Array(8)).password; * ``` */ export const mask = (mask: string): Mask => new 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: string[] = []; 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); } } } /** Public shape of a compiled password mask. */ export type MaskType = { [K in keyof Mask]: Mask[K] }; /** * Secure password mask, iOS keychain format. * @example * Generate an iOS-style password from random bytes. * ```ts * import { secureMask } from 'micro-key-producer/password.js'; * import { randomBytes } from '@noble/hashes/utils.js'; * const seed = randomBytes(32); * const pass = secureMask.apply(seed).password; * ``` */ export const secureMask: MaskType = /* @__PURE__ */ (() => { const size = BigInt(secureMasks.length); const cardinality = mask(secureMasks[0]).cardinality * size; return { length: 20, cardinality, entropy: cardinalityBits(cardinality), estimate: () => estimateAttack(cardinality), apply: (entropy: Uint8Array): ApplyResult => { let entropyLeft = bytesToNumberBE(entropy); const idx = Number(entropyLeft % size); return mask(secureMasks[idx]).apply(numberToVarBytesBE(entropyLeft / size)); }, inverse(res: ApplyResult) { const chars = res.password.split(''); const maskStr = chars .map((i) => { const possibleValues = Object.entries(alphabet) .filter(([c, _]) => ['c', 'v', 'C', 'V', '1'].includes(c)) .map(([c, v]): [string, Set<string>] => [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 = mask(secureMasks[idx]).inverse(res); const entropyNum = bytesToNumberBE(entropy); return numberToVarBytesBE(entropyNum * size + BigInt(idx)); }, }; })();