UNPKG

unixcrypt

Version:

Node.js implementation of Unixcrypt, specifically SHA-256 and SHA-512

281 lines 8.89 kB
import { createHash, timingSafeEqual, randomInt } from "node:crypto"; import { Buffer } from "node:buffer"; var HashType; (function (HashType) { HashType[HashType["sha256"] = 5] = "sha256"; HashType[HashType["sha512"] = 6] = "sha512"; })(HashType || (HashType = {})); const dictionary = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; // prettier-ignore const shuffleMap = { sha256: [ 20, 10, 0, 11, 1, 21, 2, 22, 12, 23, 13, 3, 14, 4, 24, 5, 25, 15, 26, 16, 6, 17, 7, 27, 8, 28, 18, 29, 19, 9, 30, 31 ], sha512: [ 42, 21, 0, 1, 43, 22, 23, 2, 44, 45, 24, 3, 4, 46, 25, 26, 5, 47, 48, 27, 6, 7, 49, 28, 29, 8, 50, 51, 30, 9, 10, 52, 31, 32, 11, 53, 54, 33, 12, 13, 55, 34, 35, 14, 56, 57, 36, 15, 16, 58, 37, 38, 17, 59, 60, 39, 18, 19, 61, 40, 41, 20, 62, 63, ] }; const roundsDefault = 5000; /** * Generate a random string * @param length Length of salt */ function getRandomString(length) { var result = ""; for (let i = 0; i < length; i++) { result += dictionary[randomInt(0, dictionary.length - 1)]; } return result; } /** * Normalize salt for use with hash, for example: "$6$rounds=1234&saltsalt" or "$6$saltsalt" * @param conf The separate parts of id, rounds, specifyRounds, and saltString */ function normalizeSalt(conf) { const parts = ["", conf.id]; if (conf.specifyRounds || conf.rounds !== roundsDefault) { parts.push(`rounds=${conf.rounds}`); } parts.push(conf.saltString); return parts.join("$"); } /** * Parse salt into pieces, performs sanity checks, and returns proper * defaults for missing values * @param salt Standard salt, "$6$rounds=1234$saltsalt", "$6$saltsalt", "$6", "$6$rounds=1234", "$6$" */ function parseSalt(salt) { const roundsMin = 1000; const roundsMax = 999999999; const conf = { id: HashType.sha512, saltString: getRandomString(16), rounds: roundsDefault, specifyRounds: false, }; if (salt) { const parts = salt.split("$"); conf.id = Number(parts[1]); if (conf.id !== HashType.sha256 && conf.id !== HashType.sha512) { throw new Error("Only sha256 and sha512 is supported by this library"); } if (parts.length < 2 || parts.length > 4) { throw new Error("Invalid salt string"); } if (parts.length > 2) { const rounds = parts[2].match(/^rounds=(\d*)$/); if (rounds) { // number of rounds has been specified conf.rounds = Number(rounds[1]); conf.specifyRounds = true; if (parts[3] || parts[3] === "") { conf.saltString = parts[3]; } } else { // default number of rounds has already been set conf.saltString = parts[2]; } } } // sanity-check rounds conf.rounds = conf.rounds < roundsMin ? roundsMin : conf.rounds > roundsMax ? /* istanbul ignore next */ (conf.rounds = roundsMax) : conf.rounds; // sanity-check saltString conf.saltString = conf.saltString.substring(0, 16); if (conf.saltString.match("[^./0-9A-Za-z]")) { throw new Error("Invalid salt string"); } return conf; } /** * Steps 1-12 in the spec * @param plaintext * @param conf */ function generateDigestA(plaintext, conf) { const digestSize = conf.id === HashType.sha256 ? 32 : 64; // steps 1-8 const hashA = createHash(HashType[conf.id]); hashA.update(plaintext); hashA.update(conf.saltString); const hashB = createHash(HashType[conf.id]); hashB.update(plaintext); hashB.update(conf.saltString); hashB.update(plaintext); const digestB = hashB.digest(); // step 9 const plaintextByteLength = Buffer.byteLength(plaintext); for (let offset = 0; offset + digestSize < plaintextByteLength; offset += digestSize) { hashA.update(digestB); } // step 10 const remainder = plaintextByteLength % digestSize; hashA.update(digestB.slice(0, remainder)); // step 11 plaintextByteLength .toString(2) .split("") .reverse() .forEach((num) => { hashA.update(num === "0" ? plaintext : digestB); }); // step 12 return hashA.digest(); } function generateHash(plaintext, conf) { const digestSize = conf.id === HashType.sha256 ? 32 : 64; const hashType = HashType[conf.id]; // steps 1-12 const digestA = generateDigestA(plaintext, conf); // steps 13-15 const plaintextByteLength = Buffer.byteLength(plaintext); const hashDP = createHash(hashType); for (let i = 0; i < plaintextByteLength; i++) { hashDP.update(plaintext); } const digestDP = hashDP.digest(); // step 16a const p = Buffer.alloc(plaintextByteLength); for (let offset = 0; offset + digestSize < plaintextByteLength; offset += digestSize) { p.set(digestDP, offset); } // step 16b const remainder = plaintextByteLength % digestSize; p.set(digestDP.slice(0, remainder), plaintextByteLength - remainder); // step 17-19 const hashDS = createHash(hashType); const step18 = 16 + digestA[0]; for (let i = 0; i < step18; i++) { hashDS.update(conf.saltString); } const digestDS = hashDS.digest(); // step 20 const s = Buffer.alloc(conf.saltString.length); // step 20a // Isn't this step redundant? The salt string doesn't have 32 or 64 bytes. It's truncated to 16 characters const saltByteLength = Buffer.byteLength(conf.saltString); for (let offset = 0; offset + digestSize < saltByteLength; offset += digestSize) { /* istanbul ignore next */ s.set(digestDS, offset); } // step 20b const saltRemainder = saltByteLength % digestSize; s.set(digestDS.slice(0, saltRemainder), saltByteLength - saltRemainder); // step 21 const rounds = Array(conf.rounds).fill(0); const digestC = rounds.reduce((acc, curr, idx) => { const hashC = createHash(hashType); // steps b-c if (idx % 2 === 0) { hashC.update(acc); } else { hashC.update(p); } // step d if (idx % 3 !== 0) { hashC.update(s); } // step e if (idx % 7 !== 0) { hashC.update(p); } // steps f-g if (idx % 2 !== 0) { hashC.update(acc); } else { hashC.update(p); } return hashC.digest(); }, digestA); // step 22 return base64Encode(digestC, shuffleMap[hashType]); } function base64Encode(digest, shuffleMap) { let hash = ""; for (let idx = 0; idx < digest.length; idx += 3) { const buf = Buffer.alloc(3); buf[0] = digest[shuffleMap[idx]]; buf[1] = digest[shuffleMap[idx + 1]]; buf[2] = digest[shuffleMap[idx + 2]]; hash += bufferToBase64(buf); } // adjust hash length by stripping trailing zeroes induced by base64-encoding return hash.slice(0, digest.length === 32 ? -1 : -2); } /** * Encode buffer to base64 using our dictionary * @param buf Buffer of bytes to be encoded */ function bufferToBase64(buf) { const first = buf[0] & parseInt("00111111", 2); const second = ((buf[0] & parseInt("11000000", 2)) >>> 6) | ((buf[1] & parseInt("00001111", 2)) << 2); const third = ((buf[1] & parseInt("11110000", 2)) >>> 4) | ((buf[2] & parseInt("00000011", 2)) << 4); const fourth = (buf[2] & parseInt("11111100", 2)) >>> 2; return (dictionary.charAt(first) + dictionary.charAt(second) + dictionary.charAt(third) + dictionary.charAt(fourth)); } /** * Create sha256 or sha512 crypt of plaintext password * @param plaintext The plaintext password * @param salt optional salt, for example "$6$salt" or "$6$rounds=10000$salt" */ function encrypt(plaintext, salt) { const conf = parseSalt(salt); const hash = generateHash(plaintext, conf); return normalizeSalt(conf) + "$" + hash; } /** * Verify plaintext password against expected hash * @param plaintext The plaintext password * @param hash The expected hash */ function verify(plaintext, hash) { const salt = hash.slice(0, hash.lastIndexOf("$")); const computedHash = encrypt(plaintext, salt); return timingSafeEqual(Buffer.from(computedHash, "utf8"), Buffer.from(hash, "utf8")); } export { encrypt, verify }; //# sourceMappingURL=index.js.map