import HKDF from 'hkdf'; import srp from 'srp-js'; import forge from 'node-forge'; const DEFAULT_BYTE_LENGTH = 32; const DEFAULT_PBKDF2_ITERATIONS = 1e5; // 100,000 /** * Generate hex signing key used for AES encryption * * @param pass * @param email * @param salt */ export async function deriveKey(pass, email, salt) { const combinedSalt = await _hkdfSalt(salt, email); return _pbkdf2Passphrase(pass, combinedSalt); } /** * Encrypt with RSA256 public key * * @param publicKeyJWK * @param plaintext * @return String */ export function encryptRSAWithJWK(publicKeyJWK, plaintext) { if (publicKeyJWK.alg !== 'RSA-OAEP-256') { throw new Error('Public key algorithm was not RSA-OAEP-256'); } else if (publicKeyJWK.kty !== 'RSA') { throw new Error('Public key type was not RSA'); } else if (!publicKeyJWK.key_ops.find(o => o === 'encrypt')) { throw new Error('Public key does not have "encrypt" op'); } const encodedPlaintext = encodeURIComponent(plaintext); const n = _b64UrlToBigInt(publicKeyJWK.n); const e = _b64UrlToBigInt(publicKeyJWK.e); const publicKey = forge.rsa.setPublicKey(n, e); const encrypted = publicKey.encrypt(encodedPlaintext, 'RSA-OAEP', { md: forge.md.sha256.create(), }); return forge.util.bytesToHex(encrypted); } export function decryptRSAWithJWK(privateJWK, encryptedBlob) { const n = _b64UrlToBigInt(privateJWK.n); const e = _b64UrlToBigInt(privateJWK.e); const d = _b64UrlToBigInt(privateJWK.d); const p = _b64UrlToBigInt(privateJWK.p); const q = _b64UrlToBigInt(privateJWK.q); const dP = _b64UrlToBigInt(privateJWK.dp); const dQ = _b64UrlToBigInt(privateJWK.dq); const qInv = _b64UrlToBigInt(privateJWK.qi); const privateKey = forge.rsa.setPrivateKey(n, e, d, p, q, dP, dQ, qInv); const bytes = forge.util.hexToBytes(encryptedBlob); const decrypted = privateKey.decrypt(bytes, 'RSA-OAEP', { md: forge.md.sha256.create(), }); return decodeURIComponent(decrypted); } /** * Encrypt data using symmetric key * * @param jwkOrKey JWK or string representing symmetric key * @param buff data to encrypt * @param additionalData any additional public data to attach * @returns {{iv, t, d, ad}} */ export function encryptAESBuffer(jwkOrKey, buff, additionalData = '') { // TODO: Add assertion checks for JWK const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k); const key = forge.util.hexToBytes(rawKey); const iv = forge.random.getBytesSync(12); const cipher = forge.cipher.createCipher('AES-GCM', key); cipher.start({ additionalData, iv, tagLength: 128 }); cipher.update(forge.util.createBuffer(buff)); cipher.finish(); return { iv: forge.util.bytesToHex(iv), t: forge.util.bytesToHex(cipher.mode.tag), ad: forge.util.bytesToHex(additionalData), d: forge.util.bytesToHex(cipher.output), }; } /** * Encrypt data using symmetric key * * @param jwkOrKey JWK or string representing symmetric key * @param plaintext string of data to encrypt * @param additionalData any additional public data to attach * @returns {{iv, t, d, ad}} */ export function encryptAES(jwkOrKey, plaintext, additionalData = '') { // TODO: Add assertion checks for JWK const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k); const key = forge.util.hexToBytes(rawKey); const iv = forge.random.getBytesSync(12); const cipher = forge.cipher.createCipher('AES-GCM', key); // Plaintext could contain weird unicode, so we have to encode that const encodedPlaintext = encodeURIComponent(plaintext); cipher.start({ additionalData, iv, tagLength: 128 }); cipher.update(forge.util.createBuffer(encodedPlaintext)); cipher.finish(); return { iv: forge.util.bytesToHex(iv), t: forge.util.bytesToHex(cipher.mode.tag), ad: forge.util.bytesToHex(additionalData), d: forge.util.bytesToHex(cipher.output), }; } /** * Decrypt AES using a key * * @param jwkOrKey JWK or string representing symmetric key * @param encryptedResult encryption data * @returns String */ export function decryptAES(jwkOrKey, encryptedResult) { // TODO: Add assertion checks for JWK const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k); const key = forge.util.hexToBytes(rawKey); // ~~~~~~~~~~~~~~~~~~~~ // // Decrypt with AES-GCM // // ~~~~~~~~~~~~~~~~~~~~ // const decipher = forge.cipher.createDecipher('AES-GCM', key); decipher.start({ iv: forge.util.hexToBytes(encryptedResult.iv), tagLength: encryptedResult.t.length * 4, tag: forge.util.hexToBytes(encryptedResult.t), additionalData: forge.util.hexToBytes(encryptedResult.ad), }); decipher.update(forge.util.createBuffer(forge.util.hexToBytes(encryptedResult.d))); if (decipher.finish()) { return decodeURIComponent(decipher.output.toString()); } else { throw new Error('Failed to decrypt data'); } } /** * Decrypts AES using a key to buffer * @param jwkOrKey * @param encryptedResult * @returns {string} */ export function decryptAESToBuffer(jwkOrKey, encryptedResult) { // TODO: Add assertion checks for JWK const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k); const key = forge.util.hexToBytes(rawKey); // ~~~~~~~~~~~~~~~~~~~~ // // Decrypt with AES-GCM // // ~~~~~~~~~~~~~~~~~~~~ // const decipher = forge.cipher.createDecipher('AES-GCM', key); decipher.start({ iv: forge.util.hexToBytes(encryptedResult.iv), tagLength: encryptedResult.t.length * 4, tag: forge.util.hexToBytes(encryptedResult.t), additionalData: forge.util.hexToBytes(encryptedResult.ad), }); decipher.update(forge.util.createBuffer(forge.util.hexToBytes(encryptedResult.d))); if (decipher.finish()) { return Buffer.from(forge.util.bytesToHex(decipher.output), 'hex'); } else { throw new Error('Failed to decrypt data'); } } /** * Generate a random key * * @returns {Promise} */ export function srpGenKey() { return new Promise((resolve, reject) => { srp.genKey((err, secret1Buffer) => { if (err) { reject(err); } else { resolve(secret1Buffer.toString('hex')); } }); }); } /** * Generate a random AES256 key for use with symmetric encryption */ export async function generateAES256Key() { const c = window.crypto; const subtle = c ? c.subtle || c.webkitSubtle : null; if (subtle) { console.log('[crypt] Using Native AES Key Generation'); const key = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ 'encrypt', 'decrypt', ]); return subtle.exportKey('jwk', key); } else { console.log('[crypt] Using Fallback Forge AES Key Generation'); const key = forge.util.bytesToHex(forge.random.getBytesSync(32)); return { kty: 'oct', alg: 'A256GCM', ext: true, key_ops: ['encrypt', 'decrypt'], k: _hexToB64Url(key), }; } } /** * Generate RSA keypair JWK with 2048 bits and exponent 0x10001 * * @returns Object */ export async function generateKeyPairJWK() { // NOTE: Safari has crypto.webkitSubtle, but it does not support RSA-OAEP-SHA256 const subtle = window.crypto && window.crypto.subtle; if (subtle) { console.log('[crypt] Using Native RSA Generation'); const pair = await subtle.generateKey( { name: 'RSA-OAEP', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: 2048, hash: 'SHA-256', }, true, ['encrypt', 'decrypt'], ); return { publicKey: await subtle.exportKey('jwk', pair.publicKey), privateKey: await subtle.exportKey('jwk', pair.privateKey), }; } else { console.log('[crypt] Using Forge RSA Generation'); const pair = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 }); const privateKey = { alg: 'RSA-OAEP-256', kty: 'RSA', key_ops: ['decrypt'], ext: true, d: _bigIntToB64Url(pair.privateKey.d), dp: _bigIntToB64Url(pair.privateKey.dP), dq: _bigIntToB64Url(pair.privateKey.dQ), e: _bigIntToB64Url(pair.privateKey.e), n: _bigIntToB64Url(pair.privateKey.n), p: _bigIntToB64Url(pair.privateKey.p), q: _bigIntToB64Url(pair.privateKey.q), qi: _bigIntToB64Url(pair.privateKey.qInv), }; const publicKey = { alg: 'RSA-OAEP-256', kty: 'RSA', key_ops: ['encrypt'], e: _bigIntToB64Url(pair.publicKey.e), n: _bigIntToB64Url(pair.publicKey.n), }; return { privateKey, publicKey }; } } // ~~~~~~~~~~~~~~~~ // // Helper Functions // // ~~~~~~~~~~~~~~~~ // /** * Combine email and raw salt into usable salt * * @param rawSalt * @param rawEmail * @returns {Promise} */ async function _hkdfSalt(rawSalt, rawEmail) { return new Promise(resolve => { const hkdf = new HKDF('sha256', rawSalt, rawEmail); hkdf.derive('', DEFAULT_BYTE_LENGTH, buffer => resolve(buffer.toString('hex'))); }); } /** * Convert a JSBN BigInteger to a URL-safe version of base64 encoding. This * should only be used for encoding JWKs * * @param n BigInteger * @returns {string} */ function _bigIntToB64Url(n) { return _hexToB64Url(n.toString(16)); } function _hexToB64Url(h) { const bytes = forge.util.hexToBytes(h); return window .btoa(bytes) .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function _b64UrlToBigInt(s) { return new forge.jsbn.BigInteger(_b64UrlToHex(s), 16); } function _b64UrlToHex(s) { const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); return forge.util.bytesToHex(window.atob(b64)); } /** * Derive key from password * * @param passphrase * @param salt hex representation of salt */ async function _pbkdf2Passphrase(passphrase, salt) { if (window.crypto && window.crypto.subtle) { console.log('[crypt] Using native PBKDF2'); const k = await window.crypto.subtle.importKey( 'raw', Buffer.from(passphrase, 'utf8'), { name: 'PBKDF2' }, false, ['deriveBits'], ); const algo = { name: 'PBKDF2', salt: Buffer.from(salt, 'hex'), iterations: DEFAULT_PBKDF2_ITERATIONS, hash: 'SHA-256', }; const derivedKeyRaw = await window.crypto.subtle.deriveBits(algo, k, DEFAULT_BYTE_LENGTH * 8); return Buffer.from(derivedKeyRaw).toString('hex'); } else { console.log('[crypt] Using Forge PBKDF2'); const derivedKeyRaw = forge.pkcs5.pbkdf2( passphrase, forge.util.hexToBytes(salt), DEFAULT_PBKDF2_ITERATIONS, DEFAULT_BYTE_LENGTH, forge.md.sha256.create(), ); return forge.util.bytesToHex(derivedKeyRaw); } }