-
-
Save chrisveness/770ee96945ec12ac84f134bf538d89fb to your computer and use it in GitHub Desktop.
/** | |
* 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 | |
} |
new Uint8Array(saltStr.match(/./g).map(ch => ch.charCodeAt(0)))
does not work as expected
"äõ".match(/./g).map(ch` => ch.charCodeAt(0))
// [228, 245]
new TextEncoder().encode("äo")
// [195, 164, 111]
@lauriro I think Uint8Array()
is correct for saltUint8
, as it needs to be byte-for-byte version of the salt, not a UTF-8-encoded version – I've updated my note about why TextEncoder().encode()
wouldn't work.
@chrisveness this implementation is dangerously broken under some conditions, because the number of iterations used to verify the digest is specified in the digest itself without possible verification. An attacker can send a digest with only one or zero iterations of HMAC.
-- additionally this implementation uses a non-fixed time string comparison, which allows an attacker to glean the digest (fwiw) if they are local to the system (e.g. in the same datacentre)
@chrisveness Best to normalize your text input before hashing.
const pwNrm = String(password).normalize('NFKC');
const pwUtf8 = new TextEncoder().encode(pwNrm);
Aside I'd probably use a fixed setting for the iteration count, or leave out the "version" at the beginning, and layer both the version and hard-code the iteration count in a parent wrapper method.
@Zemnmez what do you mean by "additionally this implementation uses a non-fixed time string comparison"?
@tracker1 this script uses regular equality to compare a 'good' input. regular equality is shortcut, meaning the more correct it is, the more time it will take to verify (as the comparison will bail as soon as a byte fails to compare). The security impact of this is subtle and depends a lot on where this is deployed, but in cases where an attacker can cause this to be called many times and measure response time, they can probably guess the correct PBKDF2 digest.
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>
Cryptography is indeed subtle. If I have made any errors, let me know and I will attempt to correct.
Thx to Tim Taubert @ Mozilla.