Password hashing library
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

308 lines
8.8 KiB

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;
}
}