Created
May 7, 2020 09:03
-
-
Save ObjSal/08c42252b1cb13018aea3564edc63348 to your computer and use it in GitHub Desktop.
Secure Salted Password Hashing on client and server side
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Author: Salvador Guerrero | |
'use strict' | |
const fs = require('fs') | |
const crypto = require('crypto') | |
// Project modules | |
const { CreateServer } = require('./server') | |
const SecurityUtils = require('./security-utils') | |
CreateServer((request, response, body) => { | |
if (request.url === '/' && request.method === 'GET') { | |
response.setHeader('Content-Type', 'text/html') | |
const stream = fs.createReadStream(`${__dirname}/index.html`) | |
stream.pipe(body) | |
} else if (request.url === '/' && request.method === 'POST') { | |
const contentLength = 90000000000 | |
SecurityUtils.readRequestDataInMemory(request, response, body, contentLength, (error, data) => { | |
if (error) { | |
console.error(error.message) | |
return | |
} | |
// No error, all client data, server side parsing was successful. | |
// | |
// Now we can do whatever we want with the data, in the below code | |
// I'm saving the uploaded file to the root of the node server and | |
// returning the parsed data as json, I'm removing the binary data | |
// from the response. | |
// | |
// In production this can redirect to another site that makes sense, | |
// in the below commented code it redirects to the home page: | |
// response.setHeader('Location', '/') | |
// response.statusCode = 301 | |
// body.end() | |
if (data.files) { | |
for (let file of data.files) { | |
const stream = fs.createWriteStream(file.filename) | |
stream.write(file.picture, 'binary') | |
stream.close() | |
file.picture = 'bin' | |
} | |
} | |
/////////////////////////// | |
// Hash the password | |
/////////////////////////// | |
try { | |
// Create a random salt | |
// As a rule of thumb, make your salt is at least as long as the hash function's output. | |
// The US National Institute of Standards and Technology recommends a salt length of 128 bits. | |
// Ref: https://crackstation.net/hashing-security.htm | |
// Ref: https://en.wikipedia.org/wiki/PBKDF2 | |
let saltBuffer = crypto.randomBytes(32) | |
// Convert back to Buffer for processing | |
// const saltBinary = new Buffer(saltBase64, 'base64') | |
// LastPass in 2011 used 5000 iterations for JavaScript clients and 100000 iterations for | |
// server-side hashing. | |
// Ref: https://en.wikipedia.org/wiki/PBKDF2 | |
let derivedKey = crypto.pbkdf2Sync(data.passwordHash, saltBuffer, 100000, 32, 'sha256') | |
data.serverPasswordHash = saltBuffer.toString('base64') + ':' + derivedKey.toString('base64') | |
// TODO(sal): Add a secret to 'derivedKey' using HMAC-SHA256, fetch the secret from a file in the filesystem that | |
// is not exposed to the webstie in any way. | |
/////////////////////////////////////////////////////////////// | |
// Just for kicks, validate the hash generated by the client | |
/////////////////////////////////////////////////////////////// | |
saltBuffer = new Buffer(data.password.split('').reverse().join(''), 'utf8') | |
derivedKey = crypto.pbkdf2Sync(data.password, saltBuffer, 5000, 32, 'sha256') | |
data.matchClientPasswordHash = derivedKey.toString('base64') | |
data.matched = data.matchClientPasswordHash === data.passwordHash | |
} catch (e) { | |
console.error(e) | |
} | |
response.setHeader('Content-Type', 'text/plain') | |
body.end(JSON.stringify(data)) | |
}) | |
} else { | |
response.setHeader('Content-Type', 'text/html') | |
response.statusCode = 404 | |
body.end('<html lang="en"><body><h1>Page Doesn\'t exist<h1></body></html>') | |
} | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Author: Salvador Guerrero | |
'use strict' | |
// https://nodejs.org/api/zlib.html | |
const zlib = require('zlib') | |
const kGzip = 'gzip' | |
const kDeflate = 'deflate' | |
const kBr = 'br' | |
const kAny = '*' | |
const kIdentity = 'identity' | |
class EncoderInfo { | |
constructor(name) { | |
this.name = name | |
} | |
isIdentity() { | |
return this.name === kIdentity | |
} | |
createEncoder() { | |
switch (this.name) { | |
case kGzip: return zlib.createGzip() | |
case kDeflate: return zlib.createDeflate() | |
case kBr: return zlib.createBrotliCompress() | |
default: return null | |
} | |
} | |
} | |
class ClientEncodingInfo { | |
constructor(name, qvalue) { | |
this.name = name | |
this.qvalue = qvalue | |
} | |
} | |
exports.getSupportedEncoderInfo = function getSupportedEncoderInfo(request) { | |
// See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3 | |
let acceptEncoding = request.headers['accept-encoding'] | |
let acceptEncodings = [] | |
let knownEncodings = [kGzip, kDeflate, kBr, kAny, kIdentity] | |
// If explicit is true, then it means the client sent *;q=0, meaning accept only given encodings | |
let explicit = false | |
if (!acceptEncoding || acceptEncoding.trim().length === 0) { | |
// If the Accept-Encoding field-value is empty, then only the "identity" encoding is acceptable. | |
knownEncodings = [kIdentity] | |
acceptEncodings = [new ClientEncodingInfo(kIdentity, 1)] | |
} else { | |
// NOTE: Only return 406 if the client sends 'identity;q=0' or a '*;q=0' | |
let acceptEncodingArray = acceptEncoding.split(',') | |
for (let encoding of acceptEncodingArray) { | |
encoding = encoding.trim() | |
if (/[a-z*];q=0$/.test(encoding)) { | |
// The "identity" content-coding is always acceptable, unless | |
// specifically refused because the Accept-Encoding field includes | |
// "identity;q=0", or because the field includes "*;q=0" and does | |
// not explicitly include the "identity" content-coding. | |
let split = encoding.split(';') | |
let name = split[0].trim() | |
if (name === kAny) { | |
explicit = true | |
} | |
knownEncodings.splice(knownEncodings.indexOf(name), 1) | |
} else if (/[a-z*]+;q=\d+(.\d+)*/.test(encoding)) { | |
// This string contains a qvalue. | |
let split = encoding.split(';') | |
let name = split[0].trim() | |
let value = split[1].trim() | |
value = value.split('=')[1] | |
value = parseFloat(value) | |
acceptEncodings.push(new ClientEncodingInfo(name, value)) | |
} else { | |
// No qvalue, treat it as q=1.0 | |
acceptEncodings.push(new ClientEncodingInfo(encoding.trim(), 1.0)) | |
} | |
} | |
// order by qvalue, max to min | |
acceptEncodings.sort((a, b) => { | |
return b.qvalue - a.qvalue | |
}) | |
} | |
// `acceptEncodings` is sorted by priority | |
// Pick the first known encoding. | |
let encoding = '' | |
for (let encodingInfo of acceptEncodings) { | |
if (knownEncodings.indexOf(encodingInfo.name) !== -1) { | |
encoding = encodingInfo.name | |
break | |
} | |
} | |
// If any, pick a known encoding | |
if (encoding === kAny) { | |
for (let knownEncoding of knownEncodings) { | |
if (knownEncoding === kAny) { | |
continue | |
} else { | |
encoding = knownEncoding | |
break | |
} | |
} | |
} | |
// If no known encoding was set, then use identity if not excluded | |
if (encoding.length === 0) { | |
if (!explicit && knownEncodings.indexOf(kIdentity) !== -1) { | |
encoding = kIdentity | |
} else { | |
console.error('No known encoding were found in accept-encoding, return http status code 406') | |
return null | |
} | |
} | |
return new EncoderInfo(encoding) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html lang="en"> | |
<head> | |
<title>Home</title> | |
<script> | |
'use strict' | |
function arrayBufferToBase64(buffer) { | |
return btoa(new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), '')) | |
} | |
async function securePasswordHash(password) { | |
if (!password) return null | |
// (generates a random salt) let saltBuffer = window.crypto.getRandomValues(new Uint8Array(32)) | |
let textEncoder = new TextEncoder() | |
// Use the reversed password and use it as the salt | |
let saltBuffer = textEncoder.encode(password.split('').reverse().join('')) | |
let encodedPassword = textEncoder.encode(password) | |
let baseKey = await window.crypto.subtle.importKey( | |
"raw", | |
encodedPassword, | |
"PBKDF2", | |
false, | |
["deriveBits"] | |
) | |
// LastPass in 2011 used 5000 iterations for JavaScript clients and 100000 iterations for | |
// server-side hashing. | |
// Ref: https://en.wikipedia.org/wiki/PBKDF2 | |
let keyBuffer = await window.crypto.subtle.deriveBits( | |
{ | |
"name": "PBKDF2", | |
"hash": "SHA-256", | |
salt: saltBuffer, | |
"iterations": 5000 | |
}, | |
baseKey, | |
256 | |
) | |
return arrayBufferToBase64(keyBuffer) | |
} | |
function onSubmitJSON(form) { | |
(async()=> { | |
const username = form["username2"].value | |
const password = form["password2"].value | |
const passwordHash = await securePasswordHash(password) | |
let body = JSON.stringify({ | |
username: username, | |
password: password, | |
passwordHash: passwordHash | |
}) | |
try { | |
const response = await fetch('/', { | |
headers: { | |
'content-type': 'application/json' | |
}, | |
method: 'POST', | |
body: body | |
}) | |
const text = await response.text() | |
if (response.status !== 200) { | |
if (text && text.length > 0) { | |
console.error(text) | |
} else { | |
console.error('There was an error without description') | |
} | |
return | |
} | |
document.body.innerHTML = JSON.stringify(JSON.parse(text), null, 2) | |
} catch (e) { | |
console.error(e.message) | |
} | |
})() | |
} | |
function onSubmit(form) { | |
(async()=> { | |
// Bind the FormData object and the form element | |
// FormData will always send multipart/form-data | |
const formData = new FormData(form) | |
const password = formData.get("password") | |
const passwordHash = await securePasswordHash(password) | |
formData.append("passwordHash", passwordHash) | |
try { | |
const response = await fetch('/', { | |
method: 'POST', | |
body: formData | |
}) | |
const text = await response.text() | |
if (response.status !== 200) { | |
if (text && text.length > 0) { | |
console.error(text) | |
} else { | |
console.error('There was an error without description') | |
} | |
return | |
} | |
document.body.innerHTML = JSON.stringify(JSON.parse(text), undefined, 2) | |
} catch (e) { | |
console.error(e.message) | |
} | |
})() | |
} | |
</script> | |
</head> | |
<body> | |
<h1>Form with no files (multipart/form-data)</h1> | |
<form action="javascript:" onsubmit="onSubmit(this)"> | |
<input id="username1" type="text" name="username" placeholder="username" value="sal"><br /> | |
<input id="password1" type="password" name="password" placeholder="password" value="MyW3@kP@$$!"><br /> | |
<input type="submit"> | |
</form> | |
<h1>JSON with no files (application/json)</h1> | |
<form action="javascript:" onsubmit="onSubmitJSON(this)"> | |
<input id="username2" type="text" placeholder="username" value="sal"><br /> | |
<input id="password2" type="password" placeholder="password" value="MyW3@kP@$$!"><br /> | |
<input type="submit"> | |
</form> | |
<h1>Form with files (multipart/form-data)</h1> | |
<form action="javascript:" onsubmit="onSubmit(this)"> | |
<input id="username3" type="text" name="username" placeholder="username" value="sal"><br /> | |
<input id="password3" type="password" name="password" placeholder="password" value="MyW3@kP@$$!"><br /> | |
<input id="picture3" type="file" name="picture"><br /> | |
<input type="submit"> | |
</form> | |
</body> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Author: Salvador Guerrero | |
'use strict' | |
const querystring = require('querystring') | |
const kApplicationJSON = 'application/json' | |
const kApplicationFormUrlEncoded = 'application/x-www-form-urlencoded' | |
const kMultipartFormData = 'multipart/form-data' | |
function endRequestWithError(response, body, statusCode, message, cb) { | |
response.statusCode = statusCode | |
if (message && message.length > 0) { | |
response.setHeader('Content-Type', 'application/json') | |
body.end(JSON.stringify({message: message})) | |
if (cb) cb(new Error(message)) | |
} else { | |
body.end() | |
if (cb) cb(new Error(`Error with statusCode: ${statusCode}`)) | |
} | |
} | |
function getMatching(string, regex) { | |
// Helper function when using non-matching groups | |
const matches = string.match(regex) | |
if (!matches || matches.length < 2) { | |
return null | |
} | |
return matches[1] | |
} | |
function getBoundary(contentTypeArray) { | |
const boundaryPrefix = 'boundary=' | |
let boundary = contentTypeArray.find(item => item.startsWith(boundaryPrefix)) | |
if (!boundary) return null | |
boundary = boundary.slice(boundaryPrefix.length) | |
if (boundary) boundary = boundary.trim() | |
return boundary | |
} | |
exports.readRequestDataInMemory = (request, response, body, maxLength, callback) => { | |
const contentLength = parseInt(request.headers['content-length']) | |
if (isNaN(contentLength)) { | |
endRequestWithError(response, body, 411, 'Length required', callback) | |
return | |
} | |
// Don't need to validate while reading, V8 runtime only reads what content-length specifies. | |
if (contentLength > maxLength) { | |
endRequestWithError(response, body, 413, `Content length is greater than ${maxLength} Bytes`, callback) | |
return | |
} | |
let contentType = request.headers['content-type'] | |
const contentTypeArray = contentType.split(';').map(item => item.trim()) | |
if (contentTypeArray && contentTypeArray.length) { | |
contentType = contentTypeArray[0] | |
} | |
if (!contentType) { | |
endRequestWithError(response, body, 400, 'Content type not specified', callback) | |
return | |
} | |
if (!/((application\/(json|x-www-form-urlencoded))|multipart\/form-data)/.test(contentType)) { | |
endRequestWithError(response, body, 400, 'Content type is not supported', callback) | |
return | |
} | |
if (contentType === kMultipartFormData) { | |
// Use latin1 encoding to parse binary files correctly | |
request.setEncoding('latin1') | |
} else { | |
request.setEncoding('utf8') | |
} | |
let rawData = '' | |
request.on('data', chunk => { | |
rawData += chunk | |
}) | |
request.on('end', () => { | |
switch (contentType) { | |
case kApplicationJSON: { | |
try { | |
callback(null, JSON.parse(rawData)) | |
} catch (e) { | |
endRequestWithError(response, body, 400, 'There was an error trying to parse the data as JSON') | |
callback(e) | |
} | |
break | |
} | |
case kApplicationFormUrlEncoded: { | |
try { | |
let parsedData = querystring.decode(rawData) | |
callback(null, parsedData) | |
} catch (e) { | |
endRequestWithError(response, body, 400, 'There was an error trying to parse the form data') | |
callback(e) | |
} | |
break | |
} | |
case kMultipartFormData: { | |
const boundary = getBoundary(contentTypeArray) | |
if (!boundary) { | |
endRequestWithError(response, body, 400, 'Boundary information missing', callback) | |
return | |
} | |
let result = {} | |
const rawDataArray = rawData.split(boundary) | |
for (let item of rawDataArray) { | |
// Use non-matching groups to exclude part of the result | |
let name = getMatching(item, /(?:name=")(.+?)(?:")/) | |
if (!name || !(name = name.trim())) continue | |
let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/) | |
if (!value) continue | |
let filename = getMatching(item, /(?:filename=")(.*?)(?:")/) | |
if (filename && (filename = filename.trim())) { | |
// Add the file information in a files array | |
let file = {} | |
file[name] = value | |
file['filename'] = filename | |
let contentType = getMatching(item, /(?:Content-Type:)(.*?)(?:\r\n)/) | |
if (contentType && (contentType = contentType.trim())) { | |
file['Content-Type'] = contentType | |
} | |
if (!result.files) { | |
result.files = [] | |
} | |
result.files.push(file) | |
} else { | |
// Key/Value pair | |
result[name] = value | |
} | |
} | |
callback(null, result) | |
break | |
} | |
default: { | |
callback(null, rawData) | |
} | |
} | |
}) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Author: Salvador Guerrero | |
'use strict' | |
const fs = require('fs') | |
const http = require('http') | |
const { pipeline, PassThrough } = require('stream') | |
// Project modules | |
const { getSupportedEncoderInfo } = require('./encoding-util') | |
exports.CreateServer = function CreateServer(callback) { | |
http.createServer((request, response) => { | |
let encoderInfo = getSupportedEncoderInfo(request) | |
if (!encoderInfo) { | |
// Encoded not supported by this server | |
response.statusCode = 406 | |
response.setHeader('Content-Type', 'application/json') | |
response.end(JSON.stringify({error: 'Encodings not supported'})) | |
return | |
} | |
let body = response | |
response.setHeader('Content-Encoding', encoderInfo.name) | |
// If encoding is not identity, encode the response =) | |
if (!encoderInfo.isIdentity()) { | |
const onError = (err) => { | |
if (err) { | |
// If an error occurs, there's not much we can do because | |
// the server has already sent the 200 response code and | |
// some amount of data has already been sent to the client. | |
// The best we can do is terminate the response immediately | |
// and log the error. | |
response.end() | |
console.error('An error occurred:', err) | |
} | |
} | |
body = new PassThrough() | |
pipeline(body, encoderInfo.createEncoder(), response, onError) | |
} | |
if (request.url === '/favicon.ico' && request.method === 'GET') { | |
const path = `${__dirname}/rambo.ico` | |
const contentType = 'image/vnd.microsoft.icon' | |
// Chrome & Safari have issues caching favicon's | |
response.setHeader('Content-Type', contentType) | |
fs.createReadStream(path).pipe(body) | |
} else { | |
callback(request, response, body) | |
} | |
}).listen(3000, () => { | |
console.log(`Server running at http://localhost:3000/`); | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment