Skip to content

Instantly share code, notes, and snippets.

@bmatusiak
Created May 2, 2022 08:14
Show Gist options
  • Save bmatusiak/66c7c72771f18c2010d89a7b0dbce357 to your computer and use it in GitHub Desktop.
Save bmatusiak/66c7c72771f18c2010d89a7b0dbce357 to your computer and use it in GitHub Desktop.
module.exports = function(pwd, extra) {
var forge = require("node-forge");
forge.options.usePureJavaScript = true;
var EC = require('elliptic').ec;
return new Promise((resolve, reject) => {
var ec_p256 = new EC('p256');
if (!pwd)
pwd = forge.random.getBytesSync(32);
var privateKey_d = forge.md.sha256.create().update("d").update(pwd); //decrypt key
var privateKey_s = forge.md.sha256.create().update("s").update(pwd); //sign key
if (extra) {
if (extra instanceof String)
extra = [extra];
for (let i = 0; i < extra.length; i++) {
privateKey_s = privateKey_s.update(extra[i]);
privateKey_d = privateKey_d.update(extra[i]);
}
}
privateKey_s = privateKey_s.digest().toHex();
privateKey_d = privateKey_d.digest().toHex();
var keyA_d = ec_p256.keyFromPrivate(privateKey_d, "hex");
var validation = keyA_d.validate();
if (validation.reason)
return reject(validation.reason);
var keyA_s = ec_p256.keyFromPrivate(privateKey_s, "hex");
validation = keyA_s.validate();
if (validation.reason)
return reject(validation.reason);
resolve({
pub: keyBuffer_to_jwk("ECDSA", Buffer.from(keyA_s.getPublic("hex"), "hex")),
priv: arrayBufToBase64UrlEncode(Buffer.from(privateKey_s, "hex")),
epub: keyBuffer_to_jwk("ECDH", Buffer.from(keyA_d.getPublic("hex"), "hex")),
epriv: arrayBufToBase64UrlEncode(Buffer.from(privateKey_d, "hex")),
// secret: arrayBufToBase64UrlEncode(Buffer.from(keyA_d.derive(keyA_s.getPublic()).toString("hex"), "hex"))
});
});
function arrayBufToBase64UrlEncode(buf) {
var btoa = require("btoa");
var binary = '';
var bytes = new Uint8Array(buf);
for (var i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\//g, '_')
.replace(/=/g, '')
.replace(/\+/g, '-');
}
function keyBuffer_to_jwk(type, raw_publicKeyRawBuffer) {
var key;
switch (type) {
case "ECDSA":
case "ECDH":
if (raw_publicKeyRawBuffer[0] == 4)
key = arrayBufToBase64UrlEncode(raw_publicKeyRawBuffer.slice(1, 33)) + '.' + arrayBufToBase64UrlEncode(raw_publicKeyRawBuffer.slice(33, 66));
break;
default:
key = false;
break;
}
return key;
}
};
@bmatusiak
Copy link
Author

@davay42 @draeder if you can find his post and Link it here, please 🎉

@davay42
Copy link

davay42 commented Apr 27, 2025

An updated version using @noble/curves for much smaller footprint - ~40Kb against 155 Kb of elliptic

import { p256 } from '@noble/curves/p256';

export async function derivePair(pwd, extra) {
    const TEXT_ENCODER = new TextEncoder();
    const pwdBytes = pwd
        ? (typeof pwd === 'string' ? TEXT_ENCODER.encode(normalizeString(pwd)) : pwd)
        : crypto.getRandomValues(new Uint8Array(32));

    const extras = extra
        ? (Array.isArray(extra) ? extra : [extra]).map(e => normalizeString(e.toString()))
        : [];
    const extraBuf = TEXT_ENCODER.encode(extras.join('|'));

    const combinedInput = new Uint8Array(pwdBytes.length + extraBuf.length);
    combinedInput.set(pwdBytes);
    combinedInput.set(extraBuf, pwdBytes.length);

    if (combinedInput.length < 16) {
        throw new Error(`Insufficient input entropy (${combinedInput.length})`);
    }

    const version = 'v1';
    const salts = [
        { label: 'signing', type: 'pub/priv' },
        { label: 'encryption', type: 'epub/epriv' }
    ];

    const [signingKeys, encryptionKeys] = await Promise.all(salts.map(async ({ label }) => {
        const salt = TEXT_ENCODER.encode(`${label}-${version}`);
        const privateKey = await stretchKey(combinedInput, salt);

        if (!p256.utils.isValidPrivateKey(privateKey)) {
            throw new Error(`Invalid private key for ${label}`);
        }

        const publicKey = p256.getPublicKey(privateKey, false);
        return {
            pub: keyBufferToJwk(publicKey),
            priv: arrayBufToBase64UrlEncode(privateKey)
        };
    }));

    return {
        pub: signingKeys.pub,
        priv: signingKeys.priv,
        epub: encryptionKeys.pub,
        epriv: encryptionKeys.priv
    };
}

function arrayBufToBase64UrlEncode(buf) {
    return btoa(String.fromCharCode(...buf))
        .replace(/\//g, '_').replace(/=/g, '').replace(/\+/g, '-');
}

function keyBufferToJwk(publicKeyBuffer) {
    if (publicKeyBuffer[0] !== 4) throw new Error('Invalid uncompressed public key format');
    return [
        arrayBufToBase64UrlEncode(publicKeyBuffer.slice(1, 33)), // x
        arrayBufToBase64UrlEncode(publicKeyBuffer.slice(33, 65)) // y
    ].join('.');
}

function normalizeString(str) {
    return str.normalize('NFC').trim();
}

async function stretchKey(input, salt, iterations = 300_000) {
    const baseKey = await crypto.subtle.importKey('raw', input, { name: 'PBKDF2' }, false, ['deriveBits']);
    const keyBits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, baseKey, 256);
    return new Uint8Array(keyBits);
}

@draeder
Copy link

draeder commented Apr 27, 2025

Here's a version that works in both node and browser without bundling, and without the elliptic dependency:

let _p256;
async function loadCurve() {
  if (_p256) return _p256;

  const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';

  if (isBrowser) {
    try {
      const mod = await import('@noble/curves/p256');
      _p256 = mod.p256;
    } catch (_) {
      const { p256 } = await import('https://esm.sh/@noble/curves/p256');
      _p256 = p256;
    }
  } else {
    const mod = await import('@noble/curves/p256');
    _p256 = mod.p256;
  }

  return _p256;
}

let _subtle;
async function loadCryptoSubtle() {
  if (_subtle) return _subtle;

  if (typeof window !== 'undefined' && window.crypto?.subtle) {
    _subtle = window.crypto.subtle;
  } else {
    const { webcrypto } = await import('crypto');
    if (!webcrypto?.subtle) {
      throw new Error('No SubtleCrypto available');
    }
    _subtle = webcrypto.subtle;
  }

  return _subtle;
}

const TEXT_ENCODER = new TextEncoder();
function normalizeString(s) { return s.normalize('NFC').trim(); }
function arrayBufToBase64UrlEncode(buf) {
  const str = String.fromCharCode(...new Uint8Array(buf));
  return btoa(str)
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function keyBufferToJwk(pubBuf) {
  if (pubBuf[0] !== 4) throw new Error('Expected uncompressed point');
  const x = pubBuf.slice(1, 33), y = pubBuf.slice(33, 65);
  return `${arrayBufToBase64UrlEncode(x)}.${arrayBufToBase64UrlEncode(y)}`;
}
async function stretchKey(input, salt, iterations = 300_000) {
  const subtle = await loadCryptoSubtle();
  const baseKey = await subtle.importKey('raw', input, { name: 'PBKDF2' }, false, ['deriveBits']);
  const bits = await subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
    baseKey,
    256
  );
  return new Uint8Array(bits);
}

/**
 * @param {string|Uint8Array} [pwd]
 * @param {string|string[]} [extra]
 */
export async function derivePair(pwd, extra) {
  let pwdBytes;
  if (pwd) {
    pwdBytes = typeof pwd === 'string'
      ? TEXT_ENCODER.encode(normalizeString(pwd))
      : pwd;
  } else {
    // full crypto.getRandomValues
    let cryptoGlobal = typeof window !== 'undefined' && window.crypto
      ? window.crypto
      : (await import('crypto')).webcrypto;
    const rand = new Uint8Array(32);
    cryptoGlobal.getRandomValues(rand);
    pwdBytes = rand;
  }

  const extras = extra
    ? (Array.isArray(extra) ? extra : [extra]).map(e => normalizeString(e.toString()))
    : [];
  const extraBuf = TEXT_ENCODER.encode(extras.join('|'));

  const combined = new Uint8Array(pwdBytes.length + extraBuf.length);
  combined.set(pwdBytes, 0);
  combined.set(extraBuf, pwdBytes.length);
  if (combined.length < 16) throw new Error('Insufficient input entropy.');

  const version = 'v1';
  const salts = {
    signing: TEXT_ENCODER.encode(`signing-${version}`),
    encryption: TEXT_ENCODER.encode(`encryption-${version}`)
  };

  const [privS, privD] = await Promise.all([
    stretchKey(combined, salts.signing),
    stretchKey(combined, salts.encryption)
  ]);

  const p256 = await loadCurve();
  async function makeKP(priv) {
    const pubRaw = p256.getPublicKey(priv, false);
    return {
      pub : keyBufferToJwk(pubRaw),
      priv: arrayBufToBase64UrlEncode(priv)
    };
  }
  const [sigKP, ecdhKP] = await Promise.all([makeKP(privS), makeKP(privD)]);

  return {
    ...sigKP,
    ...ecdhKP,
    epub: ecdhKP.pub,
    epriv: ecdhKP.priv
  };
}

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