From 769fd3232a780fd5745379b22df9f06583ec8e26 Mon Sep 17 00:00:00 2001 From: Yarmo Mackenbach Date: Wed, 28 Sep 2022 13:08:12 +0200 Subject: [PATCH] Add hash utilities + tests --- routes/util.js | 17 +++++++ static-src/styles.css | 12 ++++- static-src/ui.js | 102 +++++++++++++++++++++++++++++++++++++ static-src/utils.js | 72 +++++++++++++++++++++++++- test/browser.test.js | 40 +++++++++++++++ views/partials/footer.pug | 2 + views/util/argon2.pug | 29 +++++++++++ views/util/bcrypt.pug | 29 +++++++++++ views/util/index.pug | 15 ++++++ views/util/profile-url.pug | 4 +- views/util/wkd.pug | 10 ++-- 11 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 views/util/argon2.pug create mode 100644 views/util/bcrypt.pug create mode 100644 views/util/index.pug diff --git a/routes/util.js b/routes/util.js index fb14437..1e257f5 100644 --- a/routes/util.js +++ b/routes/util.js @@ -31,6 +31,9 @@ import express from 'express' const router = express.Router() +router.get('/', function(req, res) { + res.render('util/index') +}) router.get('/profile-url', function(req, res) { res.render('util/profile-url') }) @@ -59,4 +62,18 @@ router.get('/wkd/:input', function(req, res) { res.render('util/wkd', { input: req.params.input }) }) +router.get('/argon2', function(req, res) { + res.render('util/argon2') +}) +router.get('/argon2/:input', function(req, res) { + res.render('util/argon2', { input: req.params.input }) +}) + +router.get('/bcrypt', function(req, res) { + res.render('util/bcrypt') +}) +router.get('/bcrypt/:input', function(req, res) { + res.render('util/bcrypt', { input: req.params.input }) +}) + export default router diff --git a/static-src/styles.css b/static-src/styles.css index 657265d..6bc684e 100644 --- a/static-src/styles.css +++ b/static-src/styles.css @@ -92,6 +92,13 @@ body { display: block; width: 100% !important; } +.half-width { + display: block; + width: 50% !important; +} +.select-all { + user-select: all; +} /* LAYOUT */ header { @@ -401,7 +408,8 @@ pre code { form input[type="text"], form input[type="search"] { margin: 8px 0; padding: 4px; - border: 1px solid #444; + border: 1px solid #999; + border-radius: 3px; font-size: 0.9rem; } form textarea { @@ -410,7 +418,7 @@ form textarea { margin: 8px 0; resize: vertical; font-size: 0.9rem; - border: 1px solid #444; + border: 1px solid #999; } .button-wrapper { display: flex; diff --git a/static-src/ui.js b/static-src/ui.js index 1fb873c..f8652cd 100644 --- a/static-src/ui.js +++ b/static-src/ui.js @@ -43,6 +43,12 @@ const elUtilQRFP = document.body.querySelector("#form-util-qrfp") const elUtilQR = document.body.querySelector("#form-util-qr") const elUtilProfileURL = document.body.querySelector("#form-util-profile-url") +const elUtilArgon2Generation = document.body.querySelector("#form-util-argon2-generate") +const elUtilArgon2Verification = document.body.querySelector("#form-util-argon2-verify") + +const elUtilBcryptGeneration = document.body.querySelector("#form-util-bcrypt-generate") +const elUtilBcryptVerification = document.body.querySelector("#form-util-bcrypt-verify") + // Initialize UI elements and event listeners export function init() { // Register modals @@ -84,6 +90,22 @@ export function init() { if (elUtilProfileURL) { runProfileURLUtility() } + + if (elUtilArgon2Generation) { + runArgon2GenerationUtility() + } + + if (elUtilArgon2Verification) { + runArgon2VerificationUtility() + } + + if (elUtilBcryptGeneration) { + runBcryptGenerationUtility() + } + + if (elUtilBcryptVerification) { + runBcryptVerificationUtility() + } } const runEncryptionForm = () => { @@ -314,5 +336,85 @@ const runProfileURLUtility = () => { elOutput.innerText = await utils.generateProfileURL(data); }); + elInput.dispatchEvent(new Event("input")); +} + +const runArgon2GenerationUtility = () => { + elUtilArgon2Generation.onsubmit = function (evt) { + evt.preventDefault(); + } + + const elInput = elUtilArgon2Generation.querySelector(".input"), + elOutput = elUtilArgon2Generation.querySelector(".output"); + + elInput.addEventListener("input", async function(evt) { + elOutput.innerText = await utils.generateArgon2Hash(elInput.value); + }); + + elInput.dispatchEvent(new Event("input")); +} + +const runArgon2VerificationUtility = () => { + elUtilArgon2Verification.onsubmit = function (evt) { + evt.preventDefault(); + } + + const elInput = elUtilArgon2Verification.querySelector(".input"), + elHash = elUtilArgon2Verification.querySelector(".hash"), + elOutput = elUtilArgon2Verification.querySelector(".output"); + + const onInput = async function(evt) { + if (elInput.value && elHash.value) { + elOutput.innerText = await utils.verifyArgon2Hash(elInput.value, elHash.value) + ? "✅ Hash matches the input" + : "❌ Hash does not match the input"; + } else { + elOutput.innerText = "Waiting for input…" + } + } + + elInput.addEventListener("input", onInput); + elHash.addEventListener("input", onInput); + + elInput.dispatchEvent(new Event("input")); +} + +const runBcryptGenerationUtility = () => { + elUtilBcryptGeneration.onsubmit = function (evt) { + evt.preventDefault(); + } + + const elInput = elUtilBcryptGeneration.querySelector(".input"), + elOutput = elUtilBcryptGeneration.querySelector(".output"); + + elInput.addEventListener("input", async function(evt) { + elOutput.innerText = await utils.generateBcryptHash(elInput.value); + }); + + elInput.dispatchEvent(new Event("input")); +} + +const runBcryptVerificationUtility = () => { + elUtilBcryptVerification.onsubmit = function (evt) { + evt.preventDefault(); + } + + const elInput = elUtilBcryptVerification.querySelector(".input"), + elHash = elUtilBcryptVerification.querySelector(".hash"), + elOutput = elUtilBcryptVerification.querySelector(".output"); + + const onInput = async function(evt) { + if (elInput.value && elHash.value) { + elOutput.innerText = await utils.verifyBcryptHash(elInput.value, elHash.value) + ? "✅ Hash matches the input" + : "❌ Hash does not match the input"; + } else { + elOutput.innerText = "Waiting for input…" + } + } + + elInput.addEventListener("input", onInput); + elHash.addEventListener("input", onInput); + elInput.dispatchEvent(new Event("input")); } \ No newline at end of file diff --git a/static-src/utils.js b/static-src/utils.js index 32bfdfe..50e21b5 100644 --- a/static-src/utils.js +++ b/static-src/utils.js @@ -29,6 +29,7 @@ more information on this, and how to apply and follow the GNU AGPL, see > bitsLeft)]; } return result; +} + +// Generate Argon2 hash +export async function generateArgon2Hash(input) { + if (!_crypto) { + _crypto = (await import('crypto')).webcrypto + } + + const salt = new Uint8Array(16); + _crypto.getRandomValues(salt); + + try { + return await argon2id({ + password: input, + salt, + parallelism: 2, + iterations: 512, + memorySize: 64, + hashLength: 16, + outputType: 'encoded', + }); + } catch (_) { + return "Waiting for input…"; + } +} + +// Verify Argon2 hash +export async function verifyArgon2Hash(input, hash) { + try { + return await argon2Verify({ + password: input, + hash: hash, + }); + } catch (_) { + return false; + } +} + +// Generate bcrypt hash +export async function generateBcryptHash(input) { + if (!_crypto) { + _crypto = (await import('crypto')).webcrypto + } + + const salt = new Uint8Array(16); + _crypto.getRandomValues(salt); + + try { + return await bcrypt({ + password: input, + salt, + costFactor: 11, + outputType: 'encoded', + }); + } catch (_) { + return "Waiting for input…"; + } +} + +// Verify bcrypt hash +export async function verifyBcryptHash(input, hash) { + try { + return await bcryptVerify({ + password: input, + hash: hash, + }); + } catch (_) { + return false; + } } \ No newline at end of file diff --git a/test/browser.test.js b/test/browser.test.js index b0bb3dc..a6c25ef 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -25,6 +25,46 @@ describe('browser', function () { local.should.equal('iffe93qcsgp4c8ncbb378rxjo6cn9q6u') }) }) + describe('generateArgon2Hash()', async function () { + it('should generate an Argon2 hash', async function () { + const hash = await utils.generateArgon2Hash("123") + hash.should.match(/\$argon2id\$(?:.*)/) + }) + }) + describe('verifyArgon2Hash()', async function () { + it('should verify a valid Argon2 hash', async function () { + const hash = await utils.verifyArgon2Hash( + "123", + "$argon2id$v=19$m=64,t=512,p=2$2ZmmBcXDEMl6M1Bz6fvgEw$WPni+yUmwLYny1JSHcjKOQ") + hash.should.be.true + }) + it('should reject an invalid Argon2 hash', async function () { + const hash = await utils.verifyArgon2Hash( + "321", + "$argon2id$v=19$m=64,t=512,p=2$2ZmmBcXDEMl6M1Bz6fvgEw$WPni+yUmwLYny1JSHcjKOQ") + hash.should.be.false + }) + }) + describe('generateBcryptHash()', async function () { + it('should generate a bcrypt hash', async function () { + const hash = await utils.generateBcryptHash("123") + hash.should.match(/\$2a\$(?:.*)/) + }) + }) + describe('verifyBcryptHash()', async function () { + it('should verify a valid bcrypt hash', async function () { + const hash = await utils.verifyBcryptHash( + "123", + "$2a$11$yi5BcfAMmDZNbIvIeaRxzOjRCJ.GPWoKBRwGCf8iK7pYrVwiDaQdC") + hash.should.be.true + }) + it('should reject an invalid bcrypt hash', async function () { + const hash = await utils.verifyBcryptHash( + "321", + "$2a$11$yi5BcfAMmDZNbIvIeaRxzOjRCJ.GPWoKBRwGCf8iK7pYrVwiDaQdC") + hash.should.be.false + }) + }) describe('generateProfileURL()', function () { it('should handle a WKD URL', async function () { const local = await utils.generateProfileURL({ diff --git a/views/partials/footer.pug b/views/partials/footer.pug index 03764e6..4552fd4 100644 --- a/views/partials/footer.pug +++ b/views/partials/footer.pug @@ -5,6 +5,8 @@ footer h1 Keyoxide a(href="/") Homepage br + a(href="/util") Utilities + br a(href="/privacy") Privacy policy div diff --git a/views/util/argon2.pug b/views/util/argon2.pug new file mode 100644 index 0000000..e799ce4 --- /dev/null +++ b/views/util/argon2.pug @@ -0,0 +1,29 @@ +extends ../templates/base.pug + +block content + section.narrow + h1 Argon2 utility + + h2 Generate Argon2 hash + form#form-util-argon2-generate(method='post') + p + | This tool generates + a(href='https://en.wikipedia.org/wiki/Argon2') Argon2 + | hashes useful to + a(href='https://docs.keyoxide.org/understanding-keyoxide/identity-proof-formats/#Hashed_URI') conceal identity proofs + | . Be sure to include "openpgp4fpr:" for a valid proof! + h3 Input + input.input.half-width(type='text' name='input' placeholder='openpgp4fpr:…' value=input) + h3 Hash + pre + code.output.full-width.select-all Waiting for input… + + h2 Verify Argon2 hash + form#form-util-argon2-verify(method='post') + h3 Input + input.input.half-width(type='text' name='input' placeholder='openpgp4fpr:…' value=input) + h3 Hash + input.hash.half-width(type='text' name='hash' placeholder='$argon2…') + h3 Output + pre + code.output.full-width Waiting for input… \ No newline at end of file diff --git a/views/util/bcrypt.pug b/views/util/bcrypt.pug new file mode 100644 index 0000000..47781ea --- /dev/null +++ b/views/util/bcrypt.pug @@ -0,0 +1,29 @@ +extends ../templates/base.pug + +block content + section.narrow + h1 bcrypt utility + + h2 Generate bcrypt hash + form#form-util-bcrypt-generate(method='post') + p + | This tool generates + a(href='https://en.wikipedia.org/wiki/Bcrypt') bcrypt + | hashes useful to + a(href='https://docs.keyoxide.org/understanding-keyoxide/identity-proof-formats/#Hashed_URI') conceal identity proofs + | . Be sure to include "openpgp4fpr:" for a valid proof! + h3 Input + input.input(type='text' name='input' placeholder='openpgp4fpr:…' value=input) + h3 Hash + pre + code.output.full-width.select-all Waiting for input… + + h2 Verify bcrypt hash + form#form-util-bcrypt-verify(method='post') + h3 Input + input.input(type='text' name='input' placeholder='openpgp4fpr:…' value=input) + h3 Hash + input.hash(type='text' name='hash' placeholder='$2a$…') + h3 Output + pre + code.output.full-width Waiting for input… \ No newline at end of file diff --git a/views/util/index.pug b/views/util/index.pug new file mode 100644 index 0000000..a542959 --- /dev/null +++ b/views/util/index.pug @@ -0,0 +1,15 @@ +extends ../templates/base.pug + +block content + section.narrow + h1 Keyoxide utilities + p + a(href="/util/profile-url") Get the URL for a Keyoxide profile + p + a(href="/util/wkd") Get the URLs for WKD keys + p + a(href="/util/qrfp") Generate the QR code for fingerprints + p + a(href="/util/argon2") Generate and verify Argon2 hashes + p + a(href="/util/bcrypt") Generate and verify bcrypt hashes diff --git a/views/util/profile-url.pug b/views/util/profile-url.pug index 1167c7d..c368ac4 100644 --- a/views/util/profile-url.pug +++ b/views/util/profile-url.pug @@ -12,10 +12,10 @@ block content option(value='hkp') keys.openpgp.org option(value='keybase') Keybase br - input#input(type='text' name='input' placeholder='Input' value='') + input#input.half-width(type='text' name='input' placeholder='Input' value='') h3 Profile URL pre - code#output Waiting for input... + code#output Waiting for input… h3 Help p | When using the diff --git a/views/util/wkd.pug b/views/util/wkd.pug index dbaebcb..0bb0733 100644 --- a/views/util/wkd.pug +++ b/views/util/wkd.pug @@ -2,11 +2,11 @@ extends ../templates/base.pug block content section.narrow - h1 Web Key Directory generator + h1 Web Key Directory URL generator form#form-util-wkd(method='post') p | This tool computes the part of the WKD URL that corresponds to the username when - a(href='/guides/web-key-directory') uploading keys using web key directory + a(href='https://docs.keyoxide.org/web-key-directory') uploading keys using web key directory | . p | If you enter the entire WKD identifier (username@domain.org), this tool will also generate the complete URLs. @@ -17,10 +17,10 @@ block content h3 Output h4 Local part pre - code#output.full-width Waiting for input... + code#output.full-width Waiting for input… h4 Direct URL pre - code#output_url_direct.full-width Waiting for input... + code#output_url_direct.full-width Waiting for input… h4 Advanced URL pre - code#output_url_advanced.full-width Waiting for input... + code#output_url_advanced.full-width Waiting for input…