Skip to content

Instantly share code, notes, and snippets.

@chrisveness
Last active December 21, 2023 19:20
Show Gist options
  • Star 31 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save chrisveness/770ee96945ec12ac84f134bf538d89fb to your computer and use it in GitHub Desktop.
Save chrisveness/770ee96945ec12ac84f134bf538d89fb to your computer and use it in GitHub Desktop.
Uses the SubtleCrypto interface of the Web Cryptography API to hash a password using PBKDF2, and validate a stored password hash against a subsequently supplied password. Note that both bcrypt and scrypt offer better defence against ASIC/GPU attacks, but are not available within WebCrypto.
/**
* Returns PBKDF2 derived key from supplied password.
*
* Stored key can subsequently be used to verify that a password matches the original password used
* to derive the key, using pbkdf2Verify().
*
* @param {String} password - Password to be hashed using key derivation function.
* @param {Number} [iterations=1e6] - Number of iterations of HMAC function to apply.
* @returns {String} Derived key as base64 string.
*
* @example
* const key = await pbkdf2('pāşšŵōřđ'); // eg 'djAxBRKXWNWPyXgpKWHld8SWJA9CQFmLyMbNet7Rle5RLKJAkBCllLfM6tPFa7bAis0lSTiB'
*/
async function pbkdf2(password, iterations=1e6) {
const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
const saltUint8 = crypto.getRandomValues(new Uint8Array(16)); // get random salt
const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf2 params
const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
const saltArray = Array.from(new Uint8Array(saltUint8)); // salt as byte array
const iterHex = ('000000'+iterations.toString(16)).slice(-6); // iter’n count as hex
const iterArray = iterHex.match(/.{2}/g).map(byte => parseInt(byte, 16)); // iter’ns as byte array
const compositeArray = [].concat(saltArray, iterArray, keyArray); // combined array
const compositeStr = compositeArray.map(byte => String.fromCharCode(byte)).join(''); // combined as string
const compositeBase64 = btoa('v01'+compositeStr); // encode as base64
return compositeBase64; // return composite key
}
/**
* Verifies whether the supplied password matches the password previously used to generate the key.
*
* @param {String} key - Key previously generated with pbkdf2().
* @param {String} password - Password to be matched against previously derived key.
* @returns {boolean} Whether password matches key.
*
* @example
* const match = await pbkdf2Verify(key, 'pāşšŵōřđ'); // true
*/
async function pbkdf2Verify(key, password) {
let compositeStr = null; // composite key is salt, iteration count, and derived key
try { compositeStr = atob(key); } catch (e) { throw new Error ('Invalid key'); } // decode from base64
const version = compositeStr.slice(0, 3); // 3 bytes
const saltStr = compositeStr.slice(3, 19); // 16 bytes (128 bits)
const iterStr = compositeStr.slice(19, 22); // 3 bytes
const keyStr = compositeStr.slice(22, 54); // 32 bytes (256 bits)
if (version != 'v01') throw new Error('Invalid key');
// -- recover salt & iterations from stored (composite) key
const saltUint8 = new Uint8Array(saltStr.match(/./g).map(ch => ch.charCodeAt(0))); // salt as Uint8Array
// note: cannot use TextEncoder().encode(saltStr) as it generates UTF-8
const iterHex = iterStr.match(/./g).map(ch => ch.charCodeAt(0).toString(16)).join(''); // iter’n count as hex
const iterations = parseInt(iterHex, 16); // iter’ns
// -- generate new key from stored salt & iterations and supplied password
const pwUtf8 = new TextEncoder().encode(password); // encode pw as UTF-8
const pwKey = await crypto.subtle.importKey('raw', pwUtf8, 'PBKDF2', false, ['deriveBits']); // create pw key
const params = { name: 'PBKDF2', hash: 'SHA-256', salt: saltUint8, iterations: iterations }; // pbkdf params
const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); // derive key
const keyArray = Array.from(new Uint8Array(keyBuffer)); // key as byte array
const keyStrNew = keyArray.map(byte => String.fromCharCode(byte)).join(''); // key as string
return keyStrNew == keyStr; // test if newly generated key matches stored key
}
@brandonros
Copy link

brandonros commented Dec 12, 2020

This took me way longer than it should have so, anybody else who might benefit from it, here you go. I think it's secure...

<script type="text/javascript">
const deriveKeyAndIv = async (password, salt) => {
  const passwordKey = await crypto.subtle.importKey(
    'raw',
    password,
    'PBKDF2',
    false,
    ['deriveBits']
  )
  const keyLength = 32
  const ivLength = 16
  const numBits = (keyLength + ivLength) * 8
  const derviedBytes = await crypto.subtle.deriveBits({
    name: 'PBKDF2',
    hash: 'SHA-512',
    salt,
    iterations: 10000
  }, passwordKey, numBits)
  const key = await crypto.subtle.importKey(
    'raw',
    derviedBytes.slice(0, keyLength),
    'AES-GCM',
    false,
    ['encrypt', 'decrypt']
  )
  const iv = derviedBytes.slice(keyLength, keyLength + ivLength)
  return {
    key,
    iv
  }
}

const encrypt = async (password, salt, plainText) => {
  const { key, iv } = await deriveKeyAndIv(password, salt)
  return crypto.subtle.encrypt({
    name: 'AES-GCM',
    iv
  }, key, plainText)
}

const decrypt = async (password, salt, cipher) => {
  const { key, iv } = await deriveKeyAndIv(password, salt)
  return crypto.subtle.decrypt({
    name: 'AES-GCM',
    iv
  }, key, cipher)
}

const utf8ToUint8Array = (input) => new TextEncoder().encode(input)

const arrayBufferToUtf8 = (input) => new TextDecoder().decode(new Uint8Array(input))

const arrayBufferToHex = (input) => {
  input = new Uint8Array(input)
  const output = []
  for (let i = 0; i < input.length; ++i) {
    output.push(input[i].toString(16).padStart(2, '0'))
  }
  return output.join('')
}

const run = async () => {
  const password = utf8ToUint8Array('fdcf72d4-7c59-4240-a527-6630fc92fcbb')
  const salt = utf8ToUint8Array('233f9fad-7681-4ebd-ad5e-164480bbc3f5')
  const data = utf8ToUint8Array('Hello, world!')
  const cipher = await encrypt(password, salt, data)
  const plainText = await decrypt(password, salt, cipher)
  console.log(arrayBufferToHex(cipher))
  console.log(arrayBufferToUtf8(plainText))
}

run()
</script>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment