From c8c957480d29dd23c903fafcf549778a6b37c565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Mon, 11 May 2020 20:15:24 +0200 Subject: [PATCH] Initial import from hashlock --- .gitignore | 1 + .vscode/settings.json | 3 + hashing.test.ts | 80 +++++++++++ hashing.ts | 308 ++++++++++++++++++++++++++++++++++++++++++ sha.test.ts | 7 + sha.ts | 228 +++++++++++++++++++++++++++++++ tsconfig.json | 8 ++ ui/zenity.ts | 55 ++++++++ 8 files changed, 690 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 hashing.test.ts create mode 100644 hashing.ts create mode 100644 sha.test.ts create mode 100644 sha.ts create mode 100644 tsconfig.json create mode 100644 ui/zenity.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa62cbd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.config.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b943dbc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} \ No newline at end of file diff --git a/hashing.test.ts b/hashing.test.ts new file mode 100644 index 0000000..c31d6ce --- /dev/null +++ b/hashing.test.ts @@ -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"); +}); diff --git a/hashing.ts b/hashing.ts new file mode 100644 index 0000000..989e9b9 --- /dev/null +++ b/hashing.ts @@ -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; + } +} diff --git a/sha.test.ts b/sha.test.ts new file mode 100644 index 0000000..5dde34a --- /dev/null +++ b/sha.test.ts @@ -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"); +}); diff --git a/sha.ts b/sha.ts new file mode 100644 index 0000000..a693604 --- /dev/null +++ b/sha.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..99ece66 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "preserveConstEnums": true + } +} diff --git a/ui/zenity.ts b/ui/zenity.ts new file mode 100644 index 0000000..0fa1f24 --- /dev/null +++ b/ui/zenity.ts @@ -0,0 +1,55 @@ +// UI based on zenity dialogs + +import { Hasher } from "../hashing.ts"; + +async function askInput(label: string, password = false): Promise { + 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 { + const process = Deno.run({ + cmd: ["zenity", "--info", `--text=${message}`], + }); + await process.status(); +} + +async function copyToClipboard(text: string): Promise { + 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 { + 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`); +}