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 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