-
Notifications
You must be signed in to change notification settings - Fork 51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implementation of HMAC Based Token pattern #155
base: master
Are you sure you want to change the base?
Changes from all commits
eb51ece
7c3dc3d
5fbd8c1
ce64f6b
0e65a2f
c145f6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
'use strict'; | ||
|
||
const Crypto = require('crypto'); | ||
|
||
const ALGO = 'sha256'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should be configurable. |
||
/** | ||
* Returns base64-encoded ciphertext | ||
* @param {string}userId The string to encrypt | ||
* @param {string}key The secret | ||
* @returns {string}token | ||
*/ | ||
const encrypt = (userId, key) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: all instances of the variable There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. second nit: |
||
|
||
const timestamp = Date.now().toString(); | ||
const digest = Crypto.createHmac(ALGO, key) | ||
.update(userId, 'utf8') | ||
.update(timestamp, 'utf8') | ||
.digest('hex'); | ||
|
||
return `${digest}_${timestamp}`; | ||
}; | ||
|
||
/** | ||
* Validate a CSRF token generated with HMAC method. based on OWASP cheatsheet | ||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern | ||
* @param {string}enc | ||
* @param {string}userId | ||
* @param {string}key | ||
*/ | ||
const validate = (enc, userId, key) => { | ||
|
||
if (!enc || !userId || !key) { | ||
// Validation fails is one of the params is null or undefined | ||
return false; | ||
} | ||
|
||
const timestamp = Date.now().toString(); | ||
const [token_digest, token_timestamp] = enc.split('_'); //Split the encrypted string to retrieve the hmac digest and the timestamp | ||
|
||
if (!token_digest || !token_timestamp) { | ||
// Validation fails is one of the params is null or undefined | ||
return false; | ||
} | ||
|
||
const digest = Crypto.createHmac(ALGO, key) | ||
.update(userId, 'utf8') | ||
.update(token_timestamp, 'utf8') // Timestamp used at token generation | ||
.digest('hex'); | ||
|
||
return (digest === token_digest && token_timestamp <= timestamp); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. given the comparison of |
||
}; | ||
|
||
module.exports = { | ||
encrypt, | ||
validate | ||
}; |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -7,6 +7,7 @@ const Cryptiles = require('@hapi/cryptiles'); | |||||
const Hoek = require('@hapi/hoek'); | ||||||
const Validate = require('@hapi/validate'); | ||||||
|
||||||
const Hmac = require('./hmac'); | ||||||
|
||||||
const internals = { | ||||||
restfulValidatedMethods: ['post', 'put', 'patch', 'delete'] | ||||||
|
@@ -23,7 +24,16 @@ internals.schema = Validate.object().keys({ | |||||
restful: Validate.boolean().optional(), | ||||||
skip: Validate.func().optional(), | ||||||
enforce: Validate.boolean().optional(), | ||||||
logUnauthorized: Validate.boolean().optional() | ||||||
logUnauthorized: Validate.boolean().optional(), | ||||||
method: Validate.string().optional().valid('random', 'hmac'), | ||||||
secret: Validate.string().when( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
'method', | ||||||
{ is: Validate.exist(), then: Validate.required(), otherwise: Validate.optional() } | ||||||
), | ||||||
sessionKey: Validate.string().when( | ||||||
'method', | ||||||
{ is: Validate.exist(), then: Validate.required(), otherwise: Validate.optional() } | ||||||
) | ||||||
}); | ||||||
|
||||||
|
||||||
|
@@ -39,7 +49,9 @@ internals.defaults = { | |||||
restful: false, // Set to true for custom header crumb validation. Disables payload/query validation | ||||||
skip: false, // Set to a function which returns true when to skip crumb generation and validation, | ||||||
enforce: true, // Set to true for setting the CSRF cookie while not performing validation | ||||||
logUnauthorized: false // Set to true for crumb to write an event to the request log | ||||||
logUnauthorized: false, // Set to true for crumb to write an event to the request log | ||||||
method: 'random', // Define the token generation method (and therefore how to validate) | ||||||
sessionKey: 'userId' // Define which key of auth.credentials should be used during token creation if method = hmac | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i'm not sure about the name of this option, its default value, or how it functions. the recommendation from OWASP is that this is a "session ID". nothing suggests that this is necessarily the user's ID, especially given that a single user could be signed in to multiple sessions and the crumb could be valid for only one of those. my hunch (but this is very much worth discussing) is that this may be better expressed as a function that receives the at the very least, i suspect |
||||||
}; | ||||||
|
||||||
|
||||||
|
@@ -68,7 +80,8 @@ exports.plugin = { | |||||
const unauthorizedLogger = () => { | ||||||
|
||||||
if (settings.logUnauthorized) { | ||||||
request.log(['crumb', 'unauthorized'], 'validation failed'); | ||||||
const tags = ['crumb', 'unauthorized']; | ||||||
request.log(tags, 'validation failed'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this change isn't really necessary. i find the original more readable myself |
||||||
} | ||||||
}; | ||||||
|
||||||
|
@@ -130,7 +143,6 @@ exports.plugin = { | |||||
} | ||||||
|
||||||
// Validate crumb | ||||||
|
||||||
const restful = request.route.settings.plugins._crumb ? request.route.settings.plugins._crumb.restful : settings.restful; | ||||||
if (restful) { | ||||||
if (!internals.restfulValidatedMethods.includes(request.method) || !request.route.settings.plugins._crumb) { | ||||||
|
@@ -144,7 +156,13 @@ exports.plugin = { | |||||
throw Boom.forbidden(); | ||||||
} | ||||||
|
||||||
if (header !== getCrumbValue()) { | ||||||
if (settings.method !== 'hmac' && header !== getCrumbValue()) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we're setting a default of |
||||||
unauthorizedLogger(); | ||||||
throw Boom.forbidden(); | ||||||
} | ||||||
|
||||||
if (settings.method === 'hmac' && | ||||||
!Hmac.validate(header, request.auth.credentials[settings.sessionKey], settings.secret)) { | ||||||
unauthorizedLogger(); | ||||||
throw Boom.forbidden(); | ||||||
} | ||||||
|
@@ -168,11 +186,17 @@ exports.plugin = { | |||||
throw Boom.forbidden(); | ||||||
} | ||||||
|
||||||
if (content[request.route.settings.plugins._crumb.key] !== getCrumbValue()) { | ||||||
if (settings.method !== 'hmac' && content[request.route.settings.plugins._crumb.key] !== getCrumbValue()) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this one can also be an explicit |
||||||
unauthorizedLogger(); | ||||||
throw Boom.forbidden(); | ||||||
} | ||||||
|
||||||
if (settings.method === 'hmac' && | ||||||
!Hmac.validate(content[request.route.settings.plugins._crumb.key], request.auth.credentials[settings.sessionKey], settings.secret) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this line is super long, making it kind of hard to comprehend quickly. it might be best if we assigned the value of |
||||||
) { | ||||||
unauthorizedLogger(); | ||||||
throw Boom.forbidden(); | ||||||
} | ||||||
// Remove crumb | ||||||
|
||||||
delete request[request.route.settings.plugins._crumb.source][request.route.settings.plugins._crumb.key]; | ||||||
|
@@ -210,7 +234,16 @@ exports.plugin = { | |||||
const generate = function (request, h) { | ||||||
|
||||||
if (!request.plugins.crumb) { | ||||||
const crumb = Cryptiles.randomString(settings.size); | ||||||
let crumb = null; | ||||||
|
||||||
if (settings.method === 'random') { | ||||||
crumb = Cryptiles.randomString(settings.size); | ||||||
} | ||||||
|
||||||
if (settings.method === 'hmac' && (request.auth.isAuthenticated)) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: extra parens
Suggested change
|
||||||
crumb = Hmac.encrypt(request.auth.credentials[settings.sessionKey], settings.secret); | ||||||
} | ||||||
|
||||||
h.state(settings.key, crumb, settings.cookieOptions); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as mentioned in another comment, it's possible at this point for |
||||||
request.plugins.crumb = crumb; | ||||||
} | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
'use strict'; | ||
|
||
const Code = require('@hapi/code'); | ||
const Lab = require('@hapi/lab'); | ||
const Hmac = require('../lib/hmac'); | ||
|
||
const { describe, it } = exports.lab = Lab.script(); | ||
const { expect } = Code; | ||
|
||
describe('Hmac', () => { | ||
|
||
const mySecret = 'super secret!!! Do not share'; | ||
const userId = 'myUserId'; | ||
|
||
it('should export two methods', () => { | ||
|
||
expect(Hmac).to.include('encrypt') | ||
.and | ||
.to.include('validate'); | ||
|
||
expect(Hmac.encrypt).to.be.a.function(); | ||
expect(Hmac.validate).to.be.a.function(); | ||
}); | ||
|
||
describe('encrypt', () => { | ||
|
||
it('should return a digest and a timestamp concatenate with _', () => { | ||
|
||
const result = Hmac.encrypt(userId, mySecret); | ||
expect(result).to.be.a.string().and.to.include('_'); | ||
}); | ||
}); | ||
|
||
describe('validate', () => { | ||
|
||
it('should return FALSE if the token is not in the right format', () => { | ||
|
||
let encryptedStr = 'thisIsNotaGoodformat'; | ||
expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); | ||
|
||
encryptedStr = '_thisIsNotaGoodformat'; | ||
expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); | ||
}); | ||
|
||
it('should return FALSE if one of the parameters is null', () => { | ||
|
||
const encryptedStr = 'thisIsNotaGoodformat'; | ||
|
||
expect(Hmac.validate(null, userId, mySecret)).to.be.a.false(); | ||
expect(Hmac.validate(encryptedStr, null, mySecret)).to.be.a.false(); | ||
expect(Hmac.validate(encryptedStr, userId, null)).to.be.a.false(); | ||
}); | ||
|
||
it('should return TRUE if the token is valid', () => { | ||
|
||
const encryptedStr = Hmac.encrypt(userId, mySecret); | ||
expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.true(); | ||
}); | ||
|
||
it('should return FALSE if digest do not match', () => { | ||
|
||
const encryptedStr = `123456ABCD_${Date.now().toString()}`; | ||
expect(Hmac.validate(encryptedStr, userId, mySecret)).to.be.a.false(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: