-
Star
(132)
You must be signed in to star a gist -
Fork
(25)
You must be signed in to fork a gist
-
-
Save chrisveness/43bcda93af9f646d083fad678071b90a to your computer and use it in GitHub Desktop.
/** | |
* Encrypts plaintext using AES-GCM with supplied password, for decryption with aesGcmDecrypt(). | |
* (c) Chris Veness MIT Licence | |
* | |
* @param {String} plaintext - Plaintext to be encrypted. | |
* @param {String} password - Password to use to encrypt plaintext. | |
* @returns {String} Encrypted ciphertext. | |
* | |
* @example | |
* const ciphertext = await aesGcmEncrypt('my secret text', 'pw'); | |
* aesGcmEncrypt('my secret text', 'pw').then(function(ciphertext) { console.log(ciphertext); }); | |
*/ | |
async function aesGcmEncrypt(plaintext, password) { | |
const pwUtf8 = new TextEncoder().encode(password); // encode password as UTF-8 | |
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8); // hash the password | |
const iv = crypto.getRandomValues(new Uint8Array(12)); // get 96-bit random iv | |
const ivStr = Array.from(iv).map(b => String.fromCharCode(b)).join(''); // iv as utf-8 string | |
const alg = { name: 'AES-GCM', iv: iv }; // specify algorithm to use | |
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['encrypt']); // generate key from pw | |
const ptUint8 = new TextEncoder().encode(plaintext); // encode plaintext as UTF-8 | |
const ctBuffer = await crypto.subtle.encrypt(alg, key, ptUint8); // encrypt plaintext using key | |
const ctArray = Array.from(new Uint8Array(ctBuffer)); // ciphertext as byte array | |
const ctStr = ctArray.map(byte => String.fromCharCode(byte)).join(''); // ciphertext as string | |
return btoa(ivStr+ctStr); // iv+ciphertext base64-encoded | |
} | |
/** | |
* Decrypts ciphertext encrypted with aesGcmEncrypt() using supplied password. | |
* (c) Chris Veness MIT Licence | |
* | |
* @param {String} ciphertext - Ciphertext to be decrypted. | |
* @param {String} password - Password to use to decrypt ciphertext. | |
* @returns {String} Decrypted plaintext. | |
* | |
* @example | |
* const plaintext = await aesGcmDecrypt(ciphertext, 'pw'); | |
* aesGcmDecrypt(ciphertext, 'pw').then(function(plaintext) { console.log(plaintext); }); | |
*/ | |
async function aesGcmDecrypt(ciphertext, password) { | |
const pwUtf8 = new TextEncoder().encode(password); // encode password as UTF-8 | |
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8); // hash the password | |
const ivStr = atob(ciphertext).slice(0,12); // decode base64 iv | |
const iv = new Uint8Array(Array.from(ivStr).map(ch => ch.charCodeAt(0))); // iv as Uint8Array | |
const alg = { name: 'AES-GCM', iv: iv }; // specify algorithm to use | |
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']); // generate key from pw | |
const ctStr = atob(ciphertext).slice(12); // decode base64 ciphertext | |
const ctUint8 = new Uint8Array(Array.from(ctStr).map(ch => ch.charCodeAt(0))); // ciphertext as Uint8Array | |
// note: why doesn't ctUint8 = new TextEncoder().encode(ctStr) work? | |
try { | |
const plainBuffer = await crypto.subtle.decrypt(alg, key, ctUint8); // decrypt ciphertext using key | |
const plaintext = new TextDecoder().decode(plainBuffer); // plaintext from ArrayBuffer | |
return plaintext; // return the plaintext | |
} catch (e) { | |
throw new Error('Decrypt failed'); | |
} | |
} |
TextEncoder is available in Node v14 (and possibly earlier): https://nodejs.org/docs/latest-v14.x/api/util.html#util_class_util_textencoder
And also the Web Crypto API in Node v15: https://nodejs.org/api/webcrypto.html
@chriveness This little bit of code is a gem, thanks for sharing it! The only way I would improve it is using base64 encoding for the IV, concat
'ing together iv
and ctArray
before encoding the entire byte array as base64, instead of using a different encoding for the IV. As of right now, the way the result is encoded might fingerprint it from other traffic. Currently, there is exactly 24 characters of hex followed by base64 characters. With the new encoding methods, to a third party the resulting ciphertext + IV would just look like someone base64 encoded a bunch of random bytes. It also has the added bonus of saving ~4 bytes per message. Hope you have a great day!
Upsides:
- Hides better in plain sight
- Slightly smaller
Downsides:
- Isn't backwards compatible with old encrypted messages
Thanks for sharing this solution!
I was looking for a way to publish a static website and have the sources protected via a password. I created a solution using the algorithms for encryption/decryption as shown here and solved the problem.
@chrisveness Hi. Thanks for your work. Copied this code to npm package to integrate in my project. Your copyright has been saved. Please let me know if you have any concerns 🙏
https://github.com/korywka/crypto-aes-gcm
https://www.npmjs.com/package/crypto-aes-gcm
Modifying this at the top of existing code makes it usable with both browser, and Node.JS without any changes.
var crypto; if (typeof window === 'undefined') { crypto = require('crypto').webcrypto; }
// existing above code
if (typeof window === 'undefined') { exports.aesGcmEncrypt = aesGcmEncrypt; exports.aesGcmDecrypt = aesGcmDecrypt; } // Exporting it.
@korywka Awesome work 🙏
@chrisveness , could you please advice on decrypting the crypto subtle encrypted string with openssl aes-cbc-256?
Has anyone tried to decrypt crypto subtle encrypted code with openssl enc -aes-cbc-256 or openssl aes-cbc-256?
@kyo-ago , @sindresorhus , @birkir , @zhulik , @mientjan , @aadeshkulkarni-fynd , @ahmedghazi , @alfari16 , @amineoutmal , @Arjis2020 , @balde73 , @birkir , @mbaer3000 , @cobiwave , @timbru31
Thanks for the code about create key from string await crypto.subtle.digest('SHA-256', pwUtf8)
. I'm stuck at this.
This code helped me.
Thank you for the Gist, very useful! Created a variant for use with Next.js and Typescript. Removed deprecated functions (e.g. btoa
in Node). Maybe it is useful for some of you. I am not a crypto expert, so please let me know if I made a mistake.
// Based on: https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a
// Added TypeScript support and changed deprecated functions (e.g. btoa in Node.js)
/**
* Encrypts plaintext using AES-GCM with supplied password, for decryption with aesGcmDecrypt().
* (c) Chris Veness MIT Licence
*
* @param {String} plaintext - Plaintext to be encrypted.
* @param {String} password - Password to use to encrypt plaintext.
* @returns {String} Encrypted ciphertext.
*
* @example
* const ciphertext = await aesGcmEncrypt('my secret text', 'pw');
* aesGcmEncrypt('my secret text', 'pw').then(function(ciphertext) { console.log(ciphertext); });
*/
export async function aesGcmEncrypt(
plaintext: string,
password: string
): Promise<string> {
const pwUtf8 = new TextEncoder().encode(password); // encode password as UTF-8
const pwHash = await crypto.subtle.digest("SHA-256", pwUtf8); // hash the password
const iv = crypto.getRandomValues(new Uint8Array(12)); // get 96-bit random iv
const ivStr = Buffer.from(iv).toString("base64"); // iv as base64 string
const alg = { name: "AES-GCM", iv: iv }; // specify algorithm to use
const key = await crypto.subtle.importKey("raw", pwHash, alg, false, [
"encrypt",
]); // generate key from pw
const ptUint8 = new TextEncoder().encode(plaintext); // encode plaintext as UTF-8
const ctBuffer = await crypto.subtle.encrypt(alg, key, ptUint8); // encrypt plaintext using key
const ctStr = Buffer.from(ctBuffer).toString("base64"); // ciphertext as base64 string
return `${ivStr}.${ctStr}`;
}
/**
* Decrypts ciphertext encrypted with aesGcmEncrypt() using supplied password.
* (c) Chris Veness MIT Licence
*
* @param {String} ciphertext - Ciphertext to be decrypted.
* @param {String} password - Password to use to decrypt ciphertext.
* @returns {String} Decrypted plaintext.
*
* @example
* const plaintext = await aesGcmDecrypt(ciphertext, 'pw');
* aesGcmDecrypt(ciphertext, 'pw').then(function(plaintext) { console.log(plaintext); });
*/
export async function aesGcmDecrypt(
ciphertext: string,
password: string
): Promise<string> {
const pwUtf8 = new TextEncoder().encode(password); // encode password as UTF-8
const pwHash = await crypto.subtle.digest("SHA-256", pwUtf8); // hash the password
if (ciphertext.indexOf(".") === -1) {
throw new Error("Invalid ciphertext");
}
const cipherSplitted = ciphertext.split(".");
const ivStr = cipherSplitted[0]; // decode base64 iv
const iv = Buffer.from(ivStr, "base64"); // iv as Uint8Array
const alg = { name: "AES-GCM", iv: iv }; // specify algorithm to use
const key = await crypto.subtle.importKey("raw", pwHash, alg, false, [
"decrypt",
]); // generate key from pw
const ctStr = cipherSplitted[1]; // decode base64 iv
const ctUint8 = Buffer.from(ctStr, "base64"); // ciphertext as Uint8Array
try {
const plainBuffer = await crypto.subtle.decrypt(alg, key, ctUint8); // decrypt ciphertext using key
const plaintext = new TextDecoder().decode(plainBuffer); // plaintext from ArrayBuffer
return plaintext; // return the plaintext
} catch (e) {
throw new Error("Decrypt failed");
}
}
I really like and appreciate the example code @rstropek. I wanted to try and reduce repetition a little; not sure if I've fundamentally undermined the performance or anything else but this is what I came up with.
const B64 = "base64";
const CON = "|";
const textEncode = (s) => new TextEncoder().encode(s);
async function getBuffer(subject, password, iv, direction) {
const alg = { name: "AES-GCM", iv };
return [
Promise.resolve(textEncode(password)),
(password) => crypto.subtle.digest("SHA-256", password),
(pwHash) => crypto.subtle.importKey("raw", pwHash, alg, false, [direction]),
(key) => crypto.subtle[direction](alg, key, subject),
].reduce(async (input, fn) => await fn(await input));
}
export async function decrypt(ciphertext, password) {
if (!ciphertext.includes(CON)) throw new Error("Invalid ciphertext");
const [ivStr, ctStr] = ciphertext.split(CON).map((i) => Buffer.from(i, B64));
try {
return await getBuffer(ctStr, password, ivStr, "decrypt").then((val) => new TextDecoder().decode(val));
} catch (e) {
throw new Error("Decrypt failed");
}
}
export async function encrypt(plaintext, password) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const buff = await getBuffer(textEncode(plaintext), password, iv, "encrypt");
return [iv, buff].map((i) => Buffer.from(i).toString(B64)).join(CON);
}
@chrisveness thanks so much for this.
What is the purpose of slicing at
('00' + b.toString(16)).slice(-2)
?Why is each byte string padded with '00' only for these two characters to be removed?
edit now I'm starting to think this a safety precaution of some kind..
edit 2 after testing more, it looks like the '00' is absolutely needed, but not sure exactly why that is.