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