javascriptcryptographydiffie-hellman

Using WebCrypto to generate ECDH key from PBKDF2


WARNING The following is not intended as an endorsement of converting passwords to ECDH keys. Create ECDH keys from high-entropy, crypto-safe PRNGs.

I want to take a secret and generate a ECDH public/private key from it.

In the browser, a usual way would be to use PBKDF2 (or other deterministic bytes) to generate an ECDH public/private key pair in WebCrypto.

The following sample code should do this, but it throws a DOM Exception in Chrome:

// Generate a random KDF key.
const priv = new Uint8Array(24)
crypto.getRandomValues(priv)
const kdfKey = await crypto.subtle.importKey(
  'raw', priv, { name: 'PBKDF2' }, false, ['deriveKey'])

// Derive the ECDH key.
const salt = new Uint8Array(16)
const iterations = 2000
const hash = { name: 'SHA-512' }
const curve = { name: 'ECDH', namedCurve: 'P-384' }
const usages = ['deriveKey']

crypto.getRandomValues(salt)

const ecdhKey = await crypto.subtle.deriveKey({
  name: 'PBKDF2', salt, iterations, hash
}, kdfKey, curve, true, usages) // throws.

The above works when the algorithm is AES-GCM (i.e. when curve is replaced with e.g. { name: 'AES-GCM', length: 256 }), but other algorithms throw exceptions as well, so I suspect I'm missing something ... subtle.

My hope was/is that WebCrypto would be suited to accepting random bits and generating the ECDH public/private key pair. It looks like this might not be the case.

The alternative would be to use PBKDF2 to deriveBits that can be used to manually create the ECDH key pair. If this is indeed the only option, what is the usual algorithm for turning random bits into a public/private key (i.e. references & public implementations)? If I have to develop something, I'll likely post it here interest & review.

EDIT: Additional details of the use-case

The use of PBKDF is an attempt to avoid having to generate the public key (x and y) of the ECDH keypair when given the (private) d parameter. The x and y are derivative and so needn't be stored, and we've a very limited datastore — suitable only for the private key e.g. 192 bits, more-or-less (PBKDF can smooth out the bit size, too, but that's an aside).

If WebCrypto computed the x and y when given (pseudo)random d parameter, the desired outcome could be achieved/illustrated as follows:

>>> curve = { name: 'ECDH', namedCurve: 'P-256' }
>>> k = await crypto.subtle.generateKey(curve, true, ['deriveKey'])
>>> pri = await crypto.subtle.exportKey('jwk', k.privateKey)
>>> delete pri.x
>>> delete pri.y
>>> k2 = await crypto.subtle.importKey('jwk', pri)
    ^^ errors

PBKDF is used to generate (AES) keys in numerous examples. I was hoping the functionality for calculating x and y for elliptical curves, it already existing in WebCrypto, would be available through PBKDF2 deriveKey.

The do-it-yourself alternative in Javascript is to parse JWK/Base64 (URL variant), then use a large-integer function with modulo arithmetic (e.g. Fermat's Little Theorem), and finally write functions for elliptical curve point addition, doubling, and multiplication. Which is what I've done (ECC math here). But I was hoping that'd all be unnecessary, seeing as the code for doing exactly this exists in WebCrypto, and I was just hoping to use either importKey or deriveKey to wield it.

Just to reiterate: There are no user-generated passwords; using such to generate the ECDH key is considered unwise.


Solution

  • It is not possible to derive EC or RSA key pairs deterministically with deriveKey(). However, for browsers that adhere to the WebCrypto API specification, there is (at least today) a way to generate a deterministic EC key pair using the WebCrypto API alone. This is currently possible for Chrome, Edge and Opera. It is not possible for Safari and Firefox due to bugs.

    Chrome/Edge/Opera:
    For EC, the raw private key is an arbitrary byte sequence of certain length, e.g. 32 bytes for P-256, where the value must be between 0 and exclusively the order of the generator point1. This can easily be derived deterministically from a passphrase, e.g. using PBKDF2. If the key material already has a high enough entropy, HKDF can be used instead of PBKDF2. Both are supported by the WebCrypto API.
    Unfortunately, the raw private EC key generated in this way cannot be imported directly, as the WebCrypto API does not support the raw format for private EC keys. Private EC keys can only be imported in PKCS#8 or JWK format (see Supported formats). However, the JWK format is also ruled out because the public key component is mandatory (see RFC 7518, sec. 6.2.2.) and the public key is not yet known at this point. In contrast to the JWK format, the public key component is optional for the PKCS#8 format (see RFC 5208, sec. 5 and SEC1, sec. C.4). Therefore, the raw private EC key is converted to the PKCS#8 format without public key component (this conversion is curve specific, for more details see below) and can then be imported.

    The next step is to determine the public key. Unfortunately, the WebCrypto API specification does not provide a function to derive the public key from the private key. However, this is possible indirectly by exporting the private key in JWK format. Since the public key component is mandatory for the JWK format, this export forces the implementation to automatically determine the public key component internally2. In the exported key, the private key component can now be removed and the remaining key can be re-imported as JWK. The key imported in this way is now a purely public key that can be exported e.g. in X.509/SPKI format.

    1) Curve parameters like the order of the generator point can be found e.g. in SEC 2: Recommended Elliptic Curve Domain Parameters.
    2) The public key component is added not only for JWK export, but also for PKCS#8 export.


    Conversion to PKCS#8 format:

    A key in PKCS#8 format is ASN.1/DER encoded (for decoding, the keys can be loaded into an ASN.1/DER parser, e.g. https://lapo.it/asn1js/). In the case of a PKCS#8 key without public key component, the raw private key is located at the end (for P-256 it is the last 32 bytes, for P-384 the last 48 bytes and for P-521 the last 66 bytes). The curve-specific byte sequence before the raw private key (referred to below as prefix) is constant for a fixed curve. Therefore, a raw private key for curve K can be converted into a PKCS#8 key without public key component by prepending the prefix for curve K to the raw private key.

    Prefixes can be determined by generating a PKCS#8 key without a public key component for the relevant curve using e.g. OpenSSL, and removing the raw private key at the end. The following are prefixes for the curves P-256, P-384 and P-521 (hex encoded):

    P-256: 3041020100301306072a8648ce3d020106082a8648ce3d030107042730250201010420 
    P-384: 304e020100301006072a8648ce3d020106052b81040022043730350201010430 
    P-521: 3060020100301006072a8648ce3d020106052b81040023044930470201010442 
    

    Use cases:

    Use case 1: The following implementation demonstrates the deterministic key derivation using the example of ECDH keys for P-256 with subsequent generation of the shared secrets for both sides:

    (async () => {
    
    const curve = 'P-256'
    const orderHex = 'ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551' // P-256
    const prefixHex = '3041020100301306072a8648ce3d020106082a8648ce3d030107042730250201010420' // P-256
    //----------------------------------------------------------------------------------------------------------------------------
    //const curve = 'P-384'
    //const orderHex = 'ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973' // P-384
    //const prefixHex = '304e020100301006072a8648ce3d020106052b81040022043730350201010430' // P-384
    //----------------------------------------------------------------------------------------------------------------------------
    //const curve = 'P-521'
    //const orderHex = '01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409' // P-521
    //const prefixHex = '3060020100301006072a8648ce3d020106052b81040023044930470201010442' // P-521
    //----------------------------------------------------------------------------------------------------------------------------
    const size = hex2ab(orderHex).byteLength * 8
    const kdfHash = 'SHA-256'
    const kdfIterations = 100000
    
    async function genDetEcdhKeyPair(passphrase, salt) {    
        const textEncoder = new TextEncoder()
        const passphraseAB = textEncoder.encode(passphrase) 
        const saltAB = textEncoder.encode(salt) 
        // derive raw private key via PBKDF2
        const passphraseCK = await crypto.subtle.importKey('raw', passphraseAB, { name: 'PBKDF2' }, false, ['deriveBits'])
        const rawPrivateEcKeyAB = await deriveRawPrivate(saltAB, passphraseCK)
        // convert to PKCS#8
        const pkcs8nopubAB = new Uint8Array([ ...hex2ab(prefixHex), ...new Uint8Array(rawPrivateEcKeyAB)]) 
        const privateKeyCK = await crypto.subtle.importKey('pkcs8', pkcs8nopubAB, { name: 'ECDH', namedCurve: curve }, true, ['deriveBits'] )
        const pkcs8AB = await crypto.subtle.exportKey('pkcs8', privateKeyCK)
        // get public key 
        const publicKeyCK = await getPublic(privateKeyCK)
        const spkiAB = await crypto.subtle.exportKey('spki', publicKeyCK)
        return { pkcs8AB, spkiAB };
    }
    
    async function deriveRawPrivate(saltAB, passphraseCK){
        const rawKeyAB = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltAB, iterations: kdfIterations, hash: kdfHash }, passphraseCK, size)
        const nBI = BigInt('0x' + orderHex)
        let rawKeyBI = BigInt('0x' + ab2hex(rawKeyAB))
        if (rawKeyBI >= nBI){ // if derived rawKey greater than/equal to order n...
            rawKeyBI = rawKeyBI % nBI; // ...compute rawKey mod n         
        } 
        const rawKeyHex = rawKeyBI.toString(16).padStart(2*(size/8), '0') // if shorter, pad with 0x00 to fixed size
        return hex2ab(rawKeyHex)
    }
    
    async function getPublic(privateKeyCK){
        const privatKeyJWK = await crypto.subtle.exportKey('jwk', privateKeyCK)    
        delete privatKeyJWK.d
        privatKeyJWK.key_ops = []
        return crypto.subtle.importKey('jwk', privatKeyJWK, { name: 'ECDH', namedCurve: curve }, true, [])
    }
    
    function ab2hex(ab) { 
        return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
    }
    
    function hex2ab(hex){
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) { return parseInt(h, 16) }));
    }
    
    // Use case: Calculate shared secrets for ECDH -----------------------------------------------------------
    
    // 1a. Determinstic key generation, A side
    var keysA =  await genDetEcdhKeyPair('a passphrase for A side', 'some salt for A')
    console.log('PKCS#8, A:', ab2hex(keysA.pkcs8AB))
    console.log('SPKI, A:', ab2hex(keysA.spkiAB))
    // 1b. Determinstic key generation, B side
    var keysB =  await genDetEcdhKeyPair('a passphrase for B side', 'some salt for B')
    console.log('PKCS#8, B:', ab2hex(keysB.pkcs8AB))
    console.log('SPKI, B:', ab2hex(keysB.spkiAB))
    
    // 2. exchange public keys
    
    // 3a. key import, A side
    var privateKeyA = await crypto.subtle.importKey('pkcs8', keysA.pkcs8AB, { name: 'ECDH', namedCurve: curve }, true,  ['deriveBits'])
    var publicKeyB = await crypto.subtle.importKey('spki', keysB.spkiAB, { name: 'ECDH', namedCurve: curve }, true,  [])
    // 3b. key import, B side
    var privateKeyB = await crypto.subtle.importKey('pkcs8', keysB.pkcs8AB, { name: 'ECDH', namedCurve: curve }, true,  ['deriveBits'])
    var publicKeyA = await crypto.subtle.importKey('spki', keysA.spkiAB, { name: 'ECDH', namedCurve: curve }, true,  [])
    
    // 4a. calculate shared secret, A side
    var sharedSecretA = await window.crypto.subtle.deriveBits({ name: 'ECDH', public: publicKeyB }, privateKeyA, size)
    // 4b. calculate shared secret, B side
    var sharedSecretB = await window.crypto.subtle.deriveBits({ name: 'ECDH', public: publicKeyA }, privateKeyB, size)
    
    console.log('Shared secret, A:', ab2hex(sharedSecretA))
    console.log('Shared secret, B:', ab2hex(sharedSecretB))
    
    })();

    Use case 2: The following implementation demonstrates the deterministic key derivation using the example of ECDSA keys for P-521 with subsequent signing and verification of a message:

    (async () => {
    
    //const curve = 'P-256'
    //const hash = 'SHA-256'
    //const orderHex = 'ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551' // P-256
    //const prefixHex = '3041020100301306072a8648ce3d020106082a8648ce3d030107042730250201010420' // P-256
    //----------------------------------------------------------------------------------------------------------------------------
    //const curve = 'P-384'
    //const hash = 'SHA-384'
    //const orderHex = 'ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973' // P-384
    //const prefixHex = '304e020100301006072a8648ce3d020106052b81040022043730350201010430' // P-384
    //----------------------------------------------------------------------------------------------------------------------------
    const curve = 'P-521'
    const hash = 'SHA-512'
    const orderHex = '01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409' // P-521
    const prefixHex = '3060020100301006072a8648ce3d020106052b81040023044930470201010442' // P-521
    //----------------------------------------------------------------------------------------------------------------------------
    const size = hex2ab(orderHex).byteLength * 8
    const kdfHash = 'SHA-256'
    const kdfIterations = 100000
    
    async function genDetEcdsaKeyPair(passphrase, salt) {   
        const textEncoder = new TextEncoder()
        const passphraseAB = textEncoder.encode(passphrase) 
        const saltAB = textEncoder.encode(salt) 
        // derive raw private key via PBKDF2
        const passphraseCK = await crypto.subtle.importKey('raw', passphraseAB, { name: 'PBKDF2' }, false, ['deriveBits'])
        const rawPrivateEcKeyAB = await deriveRawPrivate(saltAB, passphraseCK)
        // convert to PKCS#8
        const pkcs8nopubAB = new Uint8Array([ ...hex2ab(prefixHex), ...new Uint8Array(rawPrivateEcKeyAB)]) 
        const privateKeyCK = await crypto.subtle.importKey('pkcs8', pkcs8nopubAB, { name: 'ECDSA', namedCurve: curve }, true, ['sign'] )
        const pkcs8AB = await crypto.subtle.exportKey('pkcs8', privateKeyCK)
        // get public key 
        const publicKeyCK = await getPublic(privateKeyCK)
        const spkiAB = await crypto.subtle.exportKey('spki', publicKeyCK)
        return { pkcs8AB, spkiAB };
    }
    
    async function deriveRawPrivate(saltAB, passphraseCK){
        const rawKeyAB = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltAB, iterations: kdfIterations, hash: kdfHash }, passphraseCK, size)
        const nBI = BigInt('0x' + orderHex)
        let rawKeyBI = BigInt('0x' + ab2hex(rawKeyAB))
        if (rawKeyBI >= nBI){ // if derived rawKey greater than/equal to order n...
            rawKeyBI = rawKeyBI % nBI; // ...compute rawKey mod n         
        } 
        const rawKeyHex = rawKeyBI.toString(16).padStart(2*(size/8), '0') // if shorter, pad with 0x00 to fixed size
        return hex2ab(rawKeyHex)
    }
    
    async function getPublic(privateKeyCK){
        const privatKeyJWK = await crypto.subtle.exportKey('jwk', privateKeyCK)    
        delete privatKeyJWK.d
        privatKeyJWK.key_ops = ['verify']
        return crypto.subtle.importKey('jwk', privatKeyJWK, {name: 'ECDSA', namedCurve: curve}, true, ['verify'])
    }
    
    function ab2hex(ab) { 
        return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
    }
    
    function hex2ab(hex){
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) { return parseInt(h, 16) }));
    }
    
    // Use case: Sigan and verify a message with ECDSA -----------------------------------------------------------
    
    // 1. Determinstic key generation
    var keys =  await genDetEcdsaKeyPair('a passphrase', 'some salt')
    console.log('PKCS#8:', ab2hex(keys.pkcs8AB))
    console.log('SPKI:', ab2hex(keys.spkiAB))
    
    // 2. key import
    var privateKeyCK = await crypto.subtle.importKey('pkcs8', keys.pkcs8AB, { name: 'ECDSA', namedCurve: curve }, true,  ['sign'])
    var publicKeyCK = await crypto.subtle.importKey('spki', keys.spkiAB, { name: 'ECDSA', namedCurve: curve }, true,  ['verify'])
    
    // 3. sign message
    var messageAB = new TextEncoder().encode('The quick brown fox jumps over the lazy dog')
    var signatureAB = await window.crypto.subtle.sign({ name: 'ECDSA', hash: { name: hash } }, privateKeyCK, messageAB)
    
    // 4. verify message
    var verified = await window.crypto.subtle.verify({ name: 'ECDSA', hash: { name: hash } }, publicKeyCK, signatureAB, messageAB)
    
    console.log('Signature:', ab2hex(signatureAB)) // note: Signature changes with each run despite identical keys, as WebCrypto uses the non-deterministic ECDSA variant
    console.log('Verification:', verified)
    
    })();

    Both codes are successfully executed in Chrome (v131.0.6778), Edge (v131.0.2903) and Opera (v115.0.5322).


    Safari/Firefox:
    Both browsers cannot import PKCS#8 keys without a public key component (this results in exceptions). Since the WebCrypto API specification supports the PKCS#8 format for private EC keys without restriction, and thus also the PKCS#8 format without a public key component, these are bugs, filed as Firefox bug 1743583 and Safari bug 233705. Meanwhile the Firefox bug has been fixed, i.e. a PKCS#8 key without public key component can now be imported successfully. However, the public key component is still not determined internally, so that the export as JWK fails with an exception. Since the WebCrypto API specification also supports the JWK format for private EC keys without restrictions, this is another (not yet filed) bug.

    What options are there to overcome the problem? The problem is of course solved if the Firefox and Safari bugs described above were fixed. However, the most efficient solution is to extend the WebCrypto specification with a function that extracts the public key from the private key, so that the detour via the JWK export/import is not necessary at all.

    As long as neither of these is fulfilled, the only option is to explicitly determine the public key. For EC, the public key is obtained by multiplying the raw private key by the generator point of the curve. Even if this sounds simple, it requires an implementation of the EC arithmetic. As the WebCrypto API does not expose these functionalities, an additional library is required. If an own implementation is considered, the risk of vulnerabilities (e.g. side-channel attacks) and performance issues should be kept in mind.