import { b64_hmac_sha1 } from "./sha.ts"; export enum HashOutputMode { DIGITS = "d", ALNUM = "w", CHARS = "c", } export type HashParams = Readonly<{ mode: HashOutputMode; length: number; }>; /** * Code responsible for the hashing of central password * * If an URL is provided, it will be used to obtain the site tag */ export class Hasher { private_key: string; site_tag: string | null; constructor(private_key: string, url?: string) { this.private_key = private_key; this.site_tag = (url) ? Hasher.tagFromURL(url) : null; } /** * Get a site tag from a full URL. */ static tagFromURL(url: string): string | null { if (!url) { return null; } let reg = /^https?:\/\/([^\/:]+)/; let match = reg.exec(url); if (match && match[1]) { let domain = match[1]; if (domain.indexOf(".") >= 0) { let parts = domain.split("."); parts.pop(); if (parts.length > 1 && parts[parts.length - 1] == "co") { parts.pop(); } return parts[parts.length - 1]; } else { return domain; } } else { return null; } } /** * Get the default params */ static getDefaultParams(): HashParams { return { mode: HashOutputMode.ALNUM, length: 12 }; } /** * Parse hashing params (character class and length) from a string */ static parseParams(input: string): HashParams { let params = this.getDefaultParams(); if (input.endsWith("d")) { params = Object.assign(params, { mode: HashOutputMode.DIGITS }); input = input.slice(0, input.length - 1); } else if (input.endsWith("c")) { params = Object.assign(params, { mode: HashOutputMode.CHARS }); input = input.slice(0, input.length - 1); } else if (input.endsWith("w")) { params = Object.assign(params, { mode: HashOutputMode.ALNUM }); input = input.slice(0, input.length - 1); } if (input) { params = Object.assign(params, { length: parseInt(input) }); } return params; } /** * Try hashing an input string. */ tryHashing(input: string): string | null { if (input.length > 1 && input.charAt(input.length - 1) == "#") { let base = input.substr(0, input.length - 1); let site_tag = this.site_tag; let params = Hasher.getDefaultParams(); if (base.indexOf("~") >= 0) { let smode: string; [base, smode] = base.split("~", 2); params = Hasher.parseParams(smode); } if (base.indexOf("@") >= 0) { [base, site_tag] = base.split("@", 2); } if (site_tag) { return this.getHash(base, site_tag, params); } else { return null; } } return null; } /** * Get a final hashed password, from a list of options. */ getHash(base: string, site: string, params: HashParams): string { // Hash the site name against the private key, to obtain a site key let site_key = this.generateHashWord( this.private_key, site, 24, true, true, true, false, false, ); // Hash the base password against the site key, to obtain the final key let final_hash = this.generateHashWord( site_key, base, params.length, true, params.mode == HashOutputMode.CHARS, true, params.mode != HashOutputMode.CHARS, params.mode == HashOutputMode.DIGITS, ); return final_hash; } // IMPORTANT: This function should be changed carefully. It must be // completely deterministic and consistent between releases. Otherwise // users would be forced to update their passwords. In other words, the // algorithm must always be backward-compatible. It's only acceptable to // violate backward compatibility when new options are used. // SECURITY: The optional adjustments are positioned and calculated based // on the sum of all character codes in the raw hash string. So it becomes // far more difficult to guess the injected special characters without // knowing the master key. // TODO: Is it ok to assume ASCII is ok for adjustments? generateHashWord( siteTag: string, masterKey: string, hashWordSize: number, requireDigit: boolean, requirePunctuation: boolean, requireMixedCase: boolean, restrictSpecial: boolean, restrictDigits: boolean, ) { // Start with the SHA1-encrypted master key/site tag. let s = b64_hmac_sha1(masterKey, siteTag); // Use the checksum of all characters as a pseudo-randomizing seed to // avoid making the injected characters easy to guess. Note that it // isn't random in the sense of not being deterministic (i.e. // repeatable). Must share the same seed between all injected // characters so that they are guaranteed unique positions based on // their offsets. let sum = 0; for (let i = 0; i < s.length; i++) { sum += s.charCodeAt(i); } // Restrict digits just does a mod 10 of all the characters if (restrictDigits) { s = this.convertToDigits(s, sum, hashWordSize); } else { // Inject digit, punctuation, and mixed case as needed. if (requireDigit) { s = this.injectSpecialCharacter(s, 0, 4, sum, hashWordSize, 48, 10); } if (requirePunctuation && !restrictSpecial) { s = this.injectSpecialCharacter(s, 1, 4, sum, hashWordSize, 33, 15); } if (requireMixedCase) { s = this.injectSpecialCharacter(s, 2, 4, sum, hashWordSize, 65, 26); s = this.injectSpecialCharacter(s, 3, 4, sum, hashWordSize, 97, 26); } // Strip out special characters as needed. if (restrictSpecial) { s = this.removeSpecialCharacters(s, sum, hashWordSize); } } // Trim it to size. return s.substr(0, hashWordSize); } // This is a very specialized method to inject a character chosen from a // range of character codes into a block at the front of a string if one of // those characters is not already present. // Parameters: // sInput = input string // offset = offset for position of injected character // reserved = # of offsets reserved for special characters // seed = seed for pseudo-randomizing the position and injected character // lenOut = length of head of string that will eventually survive truncation. // cStart = character code for first valid injected character. // cNum = number of valid character codes starting from cStart. injectSpecialCharacter( sInput: string, offset: number, reserved: number, seed: number, lenOut: number, cStart: number, cNum: number, ): string { let pos0 = seed % lenOut; let pos = (pos0 + offset) % lenOut; // Check if a qualified character is already present // Write the loop so that the reserved block is ignored. for (let i = 0; i < lenOut - reserved; i++) { let i2 = (pos0 + reserved + i) % lenOut; let c = sInput.charCodeAt(i2); if (c >= cStart && c < cStart + cNum) { // Already present - nothing to do return sInput; } } let sHead = (pos > 0) ? sInput.substring(0, pos) : ""; let sInject = String.fromCharCode( ((seed + sInput.charCodeAt(pos)) % cNum) + cStart, ); let sTail = (pos + 1 < sInput.length) ? sInput.substring(pos + 1, sInput.length) : ""; return sHead + sInject + sTail; } // Another specialized method to replace a class of character, e.g. // punctuation, with plain letters and numbers. // Parameters: // sInput = input string // seed = seed for pseudo-randomizing the position and injected character // lenOut = length of head of string that will eventually survive truncation. removeSpecialCharacters( sInput: string, seed: number, lenOut: number, ): string { let s = ""; let i = 0; while (i < lenOut) { let j = sInput.substring(i).search(/[^a-z0-9]/i); if (j < 0) { break; } if (j > 0) { s += sInput.substring(i, i + j); } s += String.fromCharCode((seed + i) % 26 + 65); i += j + 1; } if (i < sInput.length) { s += sInput.substring(i); } return s; } // Convert input string to digits-only. // Parameters: // sInput = input string // seed = seed for pseudo-randomizing the position and injected character // lenOut = length of head of string that will eventually survive truncation. convertToDigits(sInput: string, seed: number, lenOut: number): string { let s = ""; let i = 0; while (i < lenOut) { let j = sInput.substring(i).search(/[^0-9]/i); if (j < 0) { break; } if (j > 0) { s += sInput.substring(i, i + j); } s += String.fromCharCode((seed + sInput.charCodeAt(i)) % 10 + 48); i += j + 1; } if (i < sInput.length) { s += sInput.substring(i); } return s; } }