Created
May 2, 2022 08:14
-
-
Save bmatusiak/66c7c72771f18c2010d89a7b0dbce357 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
}; |
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 };
}
cool thanks! looks good.
@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!
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);
}
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
Here is the same as an ES6 module: