Using JavaScript Web Crypto API to generate c# compatible pbkdf2 key

Viewed 704

I'm using the following function in c# .net core 5 to generate a pbkdf2 key hash value:

HashPassword = KeyDerivation.Pbkdf2(password, SaltPassword, KeyDerivationPrf.HMACSHA256, 10000, 16);

the salt is a byte array, the password is a text string.

I need to be able to generate the same value in JavaScript. I've made it work with asmCrypto but would like to switch to the faster & standard Web Crypto API.

I believe that I need to execute this code in JavaScript (which I lifted from another example):

window.crypto.subtle.deriveBits(
    {
        name: "PBKDF2",
        hash: "SHA-256",
        salt: window.crypto.getRandomValues(new Uint8Array(16)),
        iterations: 10000
    },
    key, 
    10000)
    .then(function (bits) {
        //returns the derived bits as an ArrayBuffer
        console.log(new Uint8Array(bits));
    })
    .catch(function (err) {
        console.error(err);
    });

But I have been unsuccessful in generating a proper 'key'. I've tried with generateKey() - unsure if maybe it's importKey() - but I am unable to make it work either way. I believe generateKey needs HMAC-SHA1 to be compatible with c# pbkdf2.

Any help to make it run would be greatly appreciated. :-) Just a pointer to maybe how to generate the key and I can post the response once I validate they generate identical results.

Thank you.

-- Post answer I'm posting my final code here just in case it's useful for anyone needing a JS function as close to the C# version as possible:

/**
 * @param {string} strPassword The clear text password
 * @param {Uint8Array} salt    The salt
 * @param {string} hash        The Hash model, e.g. ["SHA-256" | "SHA-512"]
 * @param {int} iterations     Number of iterations
 * @param {int} len            The output length in bytes, e.g. 16
 */
async function pbkdf2(strPassword, salt, hash, iterations, len) {
    var password = new TextEncoder().encode(strPassword);

    var ik = await window.crypto.subtle.importKey("raw", password, { name: "PBKDF2" }, false, ["deriveBits"]);
    var dk = await window.crypto.subtle.deriveBits(
        {
            name: "PBKDF2",
            hash: hash,
            salt: salt,
            iterations: iterations
        },
        ik,
        len * 8);  // Bytes to bits

    return new Uint8Array(dk);
}
2 Answers

The posted code actually works. It just specifies the key size incorrectly (as suspected in the other answer), which may simply be a typo.
deriveBits() expects the key size in bits in the 3rd parameter. Here, the current code specifies 10000 instead of the 128 bits applied in the C# code.
With the change to 128 bits, the posted code produces the correct result (assuming the passphrase was imported correctly into a CryptoKey):

var passphrase = new TextEncoder().encode('a sample passphrase');

// Import passphrase
window.crypto.subtle.importKey("raw", passphrase, { name: "PBKDF2" }, false, ["deriveBits"])
.then(function(passphraseImported){
    
    // Derive key as ArrayBuffer
    window.crypto.subtle.deriveBits(
        {
            name: "PBKDF2",
            hash: 'SHA-256',
            salt: new TextEncoder().encode('a sample salt'), // fix for testing, otherwise window.crypto.getRandomValues(new Uint8Array(16)), 
            iterations: 10000
        },
        passphraseImported, 
        128 // Fix!
    )  
    .then(function (bits) {
        console.log("raw key:", new Uint8Array(bits)); // 7, 167, 39, 145, 34, 48, 60, 159, 242, 209, 254, 79, 78, 150, 215, 88  
        
        // If necessary, import as CryptoKey, e.g. for encryption/decryption with AES-CBC
        window.crypto.subtle.importKey("raw", bits, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
        .then(function(cryptoKey){
            console.log("CryptoKey:", cryptoKey);
        });
    }); 
});

which produces the correct result, as a comparison with e.g. CyberChef shows.

deriveBits() derives the binary data of the key without any coupling to an algorithm/mode or key usage. These are only specified when the binary data is imported into a CryptoKey with importKey().

So if you need the binary data, deriveBits() is the most efficient way. If, on the other hand, you want to generate a CryptoKey directly, the deriveKey() function suggested in the other answer is a more efficient alternative, since it saves the second import. The results are identical, of course.

var passphrase = new TextEncoder().encode('a sample passphrase');

// Import passphrase
window.crypto.subtle.importKey("raw", passphrase, { name: "PBKDF2" }, false, ["deriveKey"])
.then(function(passphraseImported){
    
    // Derive key as CryptoKey, e.g. for encryption/decryption with AES-CBC
    window.crypto.subtle.deriveKey(
        { 
            name: "PBKDF2", 
            hash: 'SHA-256', 
            salt: new TextEncoder().encode('a sample salt'), // fix for testing, otherwise window.crypto.getRandomValues(new Uint8Array(16)), 
            iterations: 10000 
        },
        passphraseImported,
        { name: 'AES-CBC', length: 128 },
        true,
        ["encrypt", "decrypt"]
    )
    .then(function(cryptoKey){
        console.log("CryptoKey:", cryptoKey);  
        
        // If necessary, export as ArrayBuffer
        window.crypto.subtle.exportKey("raw", cryptoKey).then(function (keyRaw) {                       
            console.log("raw key", new Uint8Array(keyRaw)); // 7, 167, 39, 145, 34, 48, 60, 159, 242, 209, 254, 79, 78, 150, 215, 88
        });
    });
});

I think the issue is in the key length. This is your code to generate a passwordHash (= en-/decryption key e.g. for AES):

HashPassword = KeyDerivation.Pbkdf2(password, SaltPassword, KeyDerivationPrf.HMACSHA256, 10000, 16);

I marked the "16" at the end and I believe it tells us to get a 16 bytes long key (AES-128). On WebCrypto side you need to specify this as well, so kindly note that my implementation will derive a 32 bytes = 256 bit long key, to get this to work analogue to C# you need to change the code to

{ name: mode, length: 128 },

This is my WebCrypto.subtle code (generates a 32 byte long key):

const pbkdf2 = (password, salt, iterations, hash, mode) =>
  crypto.subtle
    .importKey("raw", password, { name: "PBKDF2" }, false, ["deriveKey"])
    .then(baseKey =>
      crypto.subtle.deriveKey(
        { name: "PBKDF2", salt, iterations, hash },
        baseKey,
        { name: mode, length: 256 },
        true,
        ["encrypt", "decrypt"]
      )
    )
    .then(key => crypto.subtle.exportKey("raw", key));
Related