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;
}
};
@draeder
Copy link

draeder commented Jun 26, 2022

Here is the same as an ES6 module:

import forge from "node-forge"
import EC from 'elliptic'
import btoa from "btoa"

const ec = EC.ec

export default function Pair(pwd, extra) {

  forge.options.usePureJavaScript = true
  return new Promise((resolve, reject) => {

      let ec_p256 = new ec('p256')

      if (!pwd)
          pwd = forge.random.getBytesSync(32)

      let privateKey_d = forge.md.sha256.create().update("d").update(pwd) //decrypt key
      let 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()

      let keyA_d = ec_p256.keyFromPrivate(privateKey_d, "hex")
      let validation = keyA_d.validate()
      if (validation.reason)
          return reject(validation.reason)

      let 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) {
      let binary = ''
      let bytes = new Uint8Array(buf)
      for (let 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) {
      let 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
  }

}

@davay42
Copy link

davay42 commented Apr 25, 2025

And here's a browser version of the same code with proper Key Derivation Function used instead of just hashes - prevents brute force attacks.

import { ec as EC } from 'elliptic';

// --- Utilities ---
const TEXT_ENCODER = new TextEncoder();
const ec_p256 = new EC('p256');

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

// Convert EC public key to base64url
function keyBufferToJwk(type, publicKeyBuffer) {
    if (publicKeyBuffer[0] !== 4) return false; // Uncompressed point check
    return [
        arrayBufToBase64UrlEncode(publicKeyBuffer.slice(1, 33)), // x
        arrayBufToBase64UrlEncode(publicKeyBuffer.slice(33, 65))  // y
    ].join('.');
}

// Normalize input consistently
function normalizeString(str) {
    return str.normalize('NFC').trim();
}

// --- PBKDF2 Key Stretching (Async Utility) ---
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 // Bits, not bytes (256 bits = 32 bytes)
    );
    return new Uint8Array(keyBits);
}

// --- Final: Production-Ready Key Derivation ---
export async function derivePair(pwd, extra) {
    // --- Normalize & Encode Inputs ---
    let pwdBytes = pwd ? (typeof pwd === 'string' ? TEXT_ENCODER.encode(normalizeString(pwd)) : pwd) :
        crypto.getRandomValues(new Uint8Array(32)); // Default to random if no pwd

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

    // --- Combine Inputs & Enforce Min Entropy ---
    const combinedInput = new Uint8Array(pwdBytes.length + extraBuf.length);
    combinedInput.set(pwdBytes, 0);
    combinedInput.set(extraBuf, pwdBytes.length);
    if (combinedInput.length < 16) throw new Error("Insufficient input entropy.");

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

    // --- Derive Keys in Parallel Using Promise.all() ---
    const [privateKey_s, privateKey_d] = await Promise.all([
        stretchKey(combinedInput, salts.signing),
        stretchKey(combinedInput, salts.encryption)
    ]);

    // --- Generate & Validate EC Key Pairs ---
    const generateKeyPair = async (privateKey, type) => {
        const key = ec_p256.keyFromPrivate(privateKey, "hex");
        if (!key.validate().result) throw new Error(`Validation failed for ${type}: ${key.validate().reason}`);
        return {
            pub: keyBufferToJwk(type, new Uint8Array(key.getPublic(false, "array"))),
            priv: arrayBufToBase64UrlEncode(privateKey)
        };
    };

    const [keyA_s, keyA_d] = await Promise.all([
        generateKeyPair(privateKey_s, "ECDSA"),
        generateKeyPair(privateKey_d, "ECDH")
    ]);

    return { ...keyA_s, ...keyA_d, epub: keyA_d.pub, epriv: keyA_d.priv };
}

@bmatusiak
Copy link
Author

cool thanks! looks good.

@davay42
Copy link

davay42 commented Apr 26, 2025

@draeder mentioned this gist in a Twitter Gun chat and it's a game changer! Funny thing that it was here around for all that time. This effectively makes ANYTHING a key. And also we can derive keys from keys in a breeze! So many new opportunities to explore!

@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