Initial import from hashlock

This commit is contained in:
Michaël Lemaire 2020-05-11 20:15:24 +02:00
commit c8c957480d
8 changed files with 690 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.config.json

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"deno.enable": true
}

80
hashing.test.ts Normal file
View file

@ -0,0 +1,80 @@
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import { Hasher, HashOutputMode } from "./hashing.ts";
Deno.test("hasher", () => {
assertEquals(Hasher.tagFromURL(""), null);
assertEquals(Hasher.tagFromURL("http://"), null);
assertEquals(Hasher.tagFromURL("http://localhost"), "localhost");
assertEquals(Hasher.tagFromURL("http://localhost/"), "localhost");
assertEquals(Hasher.tagFromURL("http://localhost:22000"), "localhost");
assertEquals(Hasher.tagFromURL("http://localhost:22000/"), "localhost");
assertEquals(
Hasher.tagFromURL("http://localhost:22000/index.html"),
"localhost",
);
assertEquals(Hasher.tagFromURL("http://website.net"), "website");
assertEquals(Hasher.tagFromURL("http://website.net/"), "website");
assertEquals(Hasher.tagFromURL("http://website.co.uk"), "website");
assertEquals(Hasher.tagFromURL("http://website.co.jp/"), "website");
assertEquals(Hasher.tagFromURL("http://www.bigpage.com"), "bigpage");
assertEquals(Hasher.tagFromURL("https://www.securesite.com"), "securesite");
assertEquals(
Hasher.tagFromURL("http://domain.bigsite.com/page/path/?a=5"),
"bigsite",
);
assertEquals(
Hasher.tagFromURL("https://domains.securebigsite.com/?a=5&c=3"),
"securebigsite",
);
let hasher = new Hasher(
"5DB6DAF0-8F1D-4FB2-9845-F3E2390FB5BB",
"http://www.test.com/",
);
assertEquals(
hasher.getHash(
"password",
"test",
{ length: 12, mode: HashOutputMode.ALNUM },
),
"JNiDEjUp0oKs",
);
assertEquals(
hasher.getHash(
"password",
"test",
{ length: 16, mode: HashOutputMode.ALNUM },
),
"JNiDEjUp0oKsDFLi",
);
assertEquals(
hasher.getHash(
"password",
"test",
{ length: 12, mode: HashOutputMode.DIGITS },
),
"152463290522",
);
assertEquals(
hasher.getHash(
"password",
"test",
{ length: 12, mode: HashOutputMode.CHARS },
),
"JNi/'jUp0oKs",
);
assertEquals(hasher.tryHashing("password"), null);
assertEquals(hasher.tryHashing("password#"), "JNiDEjUp0oKs");
assertEquals(hasher.tryHashing("password@othersite#"), "D7w1bBtgYcAT");
assertEquals(hasher.tryHashing("password@othersite~d#"), "576163306621");
assertEquals(hasher.tryHashing("password@othersite~8#"), "D7w1bBtg");
assertEquals(hasher.tryHashing("password@othersite~14c#"), "DPw1bBtgYcAT!P");
assertEquals(hasher.tryHashing("password~c#"), "JNi/'jUp0oKs");
assertEquals(hasher.tryHashing("password~4#"), "JDe4");
assertEquals(hasher.tryHashing("password~10d#"), "1524632905");
hasher = new Hasher("5DB6DAF0-8F1D-4FB2-9845-F3E2390FB5BB");
assertEquals(hasher.tryHashing("password#"), null);
assertEquals(hasher.tryHashing("password@test#"), "JNiDEjUp0oKs");
});

308
hashing.ts Normal file
View file

@ -0,0 +1,308 @@
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;
}
}

7
sha.test.ts Normal file
View file

@ -0,0 +1,7 @@
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import { hex_sha1 } from "./sha.ts";
Deno.test("sha1", () => {
assertEquals(hex_sha1("abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assertEquals(hex_sha1("test"), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
});

228
sha.ts Normal file
View file

@ -0,0 +1,228 @@
/*
* A JavaScript implementation of the Secure Hash Algorithm, SHA-1, as defined
* in FIPS PUB 180-1
* Version 2.1a Copyright Paul Johnston 2000 - 2002.
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
* Distributed under the BSD License
* See http://pajhome.org.uk/crypt/md5 for details.
*/
/*
* Configurable variables. You may need to tweak these to be compatible with
* the server-side, but the defaults work in most cases.
*/
var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */
var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */
var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */
/*
* These are the functions you'll usually want to call
* They take string arguments and return either hex or base-64 encoded strings
*/
export function hex_sha1(s: string): string {
return binb2hex(core_sha1(str2binb(s), s.length * chrsz));
}
export function b64_sha1(s: string): string {
return binb2b64(core_sha1(str2binb(s), s.length * chrsz));
}
export function str_sha1(s: string): string {
return binb2str(core_sha1(str2binb(s), s.length * chrsz));
}
export function hex_hmac_sha1(key: string, data: string): string {
return binb2hex(core_hmac_sha1(key, data));
}
export function b64_hmac_sha1(key: string, data: string): string {
return binb2b64(core_hmac_sha1(key, data));
}
export function str_hmac_sha1(key: string, data: string): string {
return binb2str(core_hmac_sha1(key, data));
}
/*
* Calculate the SHA-1 of an array of big-endian words, and a bit length
*/
function core_sha1(x: number[], len: number): number[] {
/* append padding */
/* SC - Get rid of warning */
var i = (len >> 5);
if (x[i] == undefined) {
x[i] = 0x80 << (24 - len % 32);
} else {
x[i] |= 0x80 << (24 - len % 32);
}
/*x[len >> 5] |= 0x80 << (24 - len % 32);*/
x[((len + 64 >> 9) << 4) + 15] = len;
var w = Array(80);
var a = 1732584193;
var b = -271733879;
var c = -1732584194;
var d = 271733878;
var e = -1009589776;
for (var i = 0; i < x.length; i += 16) {
var olda = a;
var oldb = b;
var oldc = c;
var oldd = d;
var olde = e;
for (var j = 0; j < 80; j++) {
if (j < 16) w[j] = x[i + j];
else w[j] = rol(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
var t = safe_add(
safe_add(rol(a, 5), sha1_ft(j, b, c, d)),
safe_add(safe_add(e, w[j]), sha1_kt(j)),
);
e = d;
d = c;
c = rol(b, 30);
b = a;
a = t;
}
a = safe_add(a, olda);
b = safe_add(b, oldb);
c = safe_add(c, oldc);
d = safe_add(d, oldd);
e = safe_add(e, olde);
}
return Array(a, b, c, d, e);
}
/*
* Perform the appropriate triplet combination function for the current
* iteration
*/
function sha1_ft(t: number, b: number, c: number, d: number): number {
if (t < 20) return (b & c) | ((~b) & d);
if (t < 40) return b ^ c ^ d;
if (t < 60) return (b & c) | (b & d) | (c & d);
return b ^ c ^ d;
}
/*
* Determine the appropriate additive constant for the current iteration
*/
function sha1_kt(t: number): number {
return (t < 20)
? 1518500249
: (t < 40)
? 1859775393
: (t < 60)
? -1894007588
: -899497514;
}
/*
* Calculate the HMAC-SHA1 of a key and some data
*/
function core_hmac_sha1(key: string, data: string): number[] {
var bkey = str2binb(key);
if (bkey.length > 16) bkey = core_sha1(bkey, key.length * chrsz);
var ipad = Array(16), opad = Array(16);
for (var i = 0; i < 16; i++) {
/* SC - Get rid of warning */
var k = (bkey[i] != undefined ? bkey[i] : 0);
ipad[i] = k ^ 0x36363636;
opad[i] = k ^ 0x5C5C5C5C;
/* ipad[i] = bkey[i] ^ 0x36363636;
opad[i] = bkey[i] ^ 0x5C5C5C5C;*/
}
var hash = core_sha1(ipad.concat(str2binb(data)), 512 + data.length * chrsz);
return core_sha1(opad.concat(hash), 512 + 160);
}
/*
* Add integers, wrapping at 2^32. This uses 16-bit operations internally
* to work around bugs in some JS interpreters.
*/
function safe_add(x: number, y: number) {
var lsw = (x & 0xFFFF) + (y & 0xFFFF);
var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xFFFF);
}
/*
* Bitwise rotate a 32-bit number to the left.
*/
function rol(num: number, cnt: number) {
return (num << cnt) | (num >>> (32 - cnt));
}
/*
* Convert an 8-bit or 16-bit string to an array of big-endian words
* In 8-bit function, characters >255 have their hi-byte silently ignored.
*/
function str2binb(str: string): number[] {
var bin = Array();
var mask = (1 << chrsz) - 1;
/* SC - Get rid of warnings */
for (var i = 0; i < str.length * chrsz; i += chrsz) {
if (bin[i >> 5] != undefined) {
bin[i >> 5] |=
(str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i % 32);
} else {
bin[i >> 5] = (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i % 32);
}
}
/*for(var i = 0; i < str.length * chrsz; i += chrsz)
bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (32 - chrsz - i%32);*/
return bin;
}
/*
* Convert an array of big-endian words to a string
*/
function binb2str(bin: number[]): string {
var str = "";
var mask = (1 << chrsz) - 1;
for (var i = 0; i < bin.length * 32; i += chrsz) {
str += String.fromCharCode((bin[i >> 5] >>> (32 - chrsz - i % 32)) & mask);
}
return str;
}
/*
* Convert an array of big-endian words to a hex string.
*/
function binb2hex(binarray: number[]): string {
var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
var str = "";
for (var i = 0; i < binarray.length * 4; i++) {
str += hex_tab.charAt((binarray[i >> 2] >> ((3 - i % 4) * 8 + 4)) & 0xF) +
hex_tab.charAt((binarray[i >> 2] >> ((3 - i % 4) * 8)) & 0xF);
}
return str;
}
/*
* Convert an array of big-endian words to a base-64 string
*/
function binb2b64(binarray: number[]): string {
var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var str = "";
for (var i = 0; i < binarray.length * 4; i += 3) {
/* SC - Get rid of warning */
var b1 = binarray[i >> 2] != undefined
? ((binarray[i >> 2] >> 8 * (3 - i % 4)) & 0xFF) << 16
: 0;
var b2 = binarray[i + 1 >> 2] != undefined
? ((binarray[i + 1 >> 2] >> 8 * (3 - (i + 1) % 4)) & 0xFF) << 8
: 0;
var b3 = binarray[i + 2 >> 2] != undefined
? ((binarray[i + 2 >> 2] >> 8 * (3 - (i + 2) % 4)) & 0xFF)
: 0;
var triplet = b1 | b2 | b3;
/*var triplet = (((binarray[i >> 2] >> 8 * (3 - i %4)) & 0xFF) << 16)
| (((binarray[i+1 >> 2] >> 8 * (3 - (i+1)%4)) & 0xFF) << 8 )
| ((binarray[i+2 >> 2] >> 8 * (3 - (i+2)%4)) & 0xFF);*/
for (var j = 0; j < 4; j++) {
if (i * 8 + j * 6 > binarray.length * 32) str += b64pad;
else str += tab.charAt((triplet >> 6 * (3 - j)) & 0x3F);
}
}
return str;
}

8
tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"preserveConstEnums": true
}
}

55
ui/zenity.ts Normal file
View file

@ -0,0 +1,55 @@
// UI based on zenity dialogs
import { Hasher } from "../hashing.ts";
async function askInput(label: string, password = false): Promise<string> {
const process = Deno.run({
cmd: ["zenity", password ? "--password" : "--entry", `--text=${label}`],
stdout: "piped",
});
const output = await process.output();
const result = new TextDecoder().decode(output).replace(/[ \n]+$/, "");
return result;
}
async function showMessage(message: string): Promise<void> {
const process = Deno.run({
cmd: ["zenity", "--info", `--text=${message}`],
});
await process.status();
}
async function copyToClipboard(text: string): Promise<void> {
const process = Deno.run({
cmd: ["xclip"],
stdin: "piped",
});
if (process.stdin) {
await Deno.writeAll(process.stdin, new TextEncoder().encode(text));
process.stdin.close();
}
await process.status();
}
async function readPrivateKey(): Promise<string> {
const content = await Deno.readTextFile(".config.json");
const config = JSON.parse(content);
return config.privateKey;
}
const privateKey = await readPrivateKey();
const siteTag = await askInput("site tag:");
let password = await askInput("password:", true);
if (password.slice(password.length - 1) != "#") {
password += "#";
}
const hasher = new Hasher(privateKey);
hasher.site_tag = siteTag;
const result = hasher.tryHashing(password);
if (result) {
await copyToClipboard(result);
await showMessage(`Your hashed password (copied to clipboard): ${result}`);
} else {
await showMessage(`Error in hashing password`);
}