Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Uses the SubtleCrypto interface of the Web Cryptography API to encrypt and decrypt text using AES-GCM (AES Galois counter mode).
/**
* 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');
}
}
@chrisveness
Copy link
Author

chrisveness commented Feb 17, 2017

Cryptography is indeed subtle. If I have made any errors, let me know and I will attempt to correct.

Thx to Tim Taubert @ Mozilla.

Note: Revised Oct 2021 following @jeffro256's suggestion to encode IV as base64; this version is improved but incompatible with the previous one. References below to match() regular expressions and padStart('0') are no longer relevant.

@chrisveness
Copy link
Author

chrisveness commented Feb 28, 2017

These routines will also run on Node.js, and will inter-operate between the browser and the server.

Simply add the following at the top of the file:

const crypto = require('crypto').webcrypto; // or node-webcrypto-ossl prior to Node.js v15
module.exports = { aesGcmEncrypt, aesGcmDecrypt };

Then to use it,

const { aesGcmEncrypt, aesGcmDecrypt } = require ('./crypto-aes-gcm.js');

const ciphertext = await aesGcmEncrypt('my secret text', 'pw');
const plaintext = await aesGcmDecrypt(ciphertext, 'pw');

@majodi
Copy link

majodi commented Nov 23, 2017

match(/./g) is leaving out whitespace chars, making the Decrypt fail when ctStr contains a return or linefeed for instance. For now I am using a for-loop with charCodeAt() but maybe there's a more efficient way.

@sohrabsaran
Copy link

sohrabsaran commented Nov 26, 2017

Hi @chrisveness ,
I'm a newbie to GitHubGist...
Haven't yet tried out the above pieces of code, but it looks pretty useful.
What is the license for the code that you've shared above (even though it's a couple of lines of wrapper code around webcrypto)?
For some reason I'm unable to find clear information on this.
I'm planning to use this on an open source project, but still want to be clear on the licensing aspects.

Also by the way, I'm assuming that there is no known symmetric-key encryption system significantly more secure than AES-256-GCM.
Please confirm my understanding?
Also, can you recommend any similar gists for asymmetric key encryption?

Thanks,
Sohrab

@sohrabsaran
Copy link

sohrabsaran commented Nov 27, 2017

Hi @majodi,
Can you please share your fix for decrypt?

Thanks,
Sohrab

@gnadelwartz
Copy link

gnadelwartz commented Dec 2, 2017

i want to say a big thanks to you. this is the first time I found simple, useable and readable crypto libs made for casual users.
no need to bother with module api's, initialisation etc simply call encrypt/decrypt!

@gnadelwartz
Copy link

gnadelwartz commented Dec 2, 2017

hm, not so easy then expected. even your code use await internally it returns a promise, not a string. So i can't simply do an

result = aesGcmEncrypt('text', 'pw');
enSaveValue(result);

gives me in Firefox:
"EnSaveValue URL: https://dealz.rrr.de/enstyler/save.php?ID=-378385905&value=[object Promise]"

looks like I have to convert over everything to async :-(, because it seems not easy to getb the value a promise from for not async code ...

@sohrabsaran
Copy link

sohrabsaran commented Dec 2, 2017

@majodi,

You can replace:

 const ctUint8 = new Uint8Array(ctStr.match(/./g).map(ch => ch.charCodeAt(0)));     // ciphertext as Uint8Array

...with:

const ctUint8 = new Uint8Array(ctStr.match(/[\s\S]/g).map(ch => ch.charCodeAt(0)));     // ciphertext as Uint8Array

See also https://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work

@chrisveness
Copy link
Author

chrisveness commented Mar 10, 2018

@majodi, thanks for picking up the case where the ciphertext includes line terminators, and @sohrabsaran for providing a nice, clean fix.

@sohrabsaran, I've added a (MIT) licence notice. As far as I'm aware, AES-256-GCM is pretty much tops. I'm afraid I can't help with asymmetric key encryption.

@gnadelwartz I've added an alternative syntax to the examples using .then() rather than await, but there is no way around using promises, the Web Cryptography API is promise-based.

@timbru31
Copy link

timbru31 commented Apr 12, 2018

Amazing gist, thanks a ton! I'd like to add a note, that Safari 10 (and iOS <11) does not support AES-GCM, but AES-CBC. To use AES-CBC the initializationVector (iv) needs to be a crypto.getRandomValues(new Uint8Array(16)); and the const ctStr needs to be atob(ciphertext.slice(32));

@Xeoncross
Copy link

Xeoncross commented Apr 13, 2018

Thank you for sharing. I am trying to convert this to AES-CTR but having some problems getting the decoding correct:

/**
 * Encrypts plaintext using AES-CTR with supplied password, for decryption with aesCtrDecrypt().
 *                                                                      (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.
 */
async function aesCtrEncrypt(plaintext, password) {
  const data = new TextEncoder().encode(plaintext);  
  const iv = crypto.getRandomValues(new Uint8Array(16));                             // get 120-bit random iv
  const alg = {name: "AES-CTR", counter: iv, length: 128};
  console.log('iv', iv);
  
  // Generate key
  // const key = await crypto.subtle.generateKey(
  //     {
  //         name: "AES-CTR",
  //         length: 256, //can be  128, 192, or 256
  //     },
  //     false, //whether the key is extractable (i.e. can be used in exportKey)
  //     ["encrypt", "decrypt"] //can "encrypt", "decrypt", "wrapKey", or "unwrapKey"
  // );
  
  // Use provided key
  const pwUtf8 = new TextEncoder().encode(password);                                 // encode password as UTF-8
  const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);                      // hash the password
  const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['encrypt']); // generate key from pw
  
  const ctBuffer = await crypto.subtle.encrypt(alg, key, data)

  const ctArray = Array.from(new Uint8Array(ctBuffer));                              // ciphertext as byte array
  const ctStr = ctArray.map(byte => String.fromCharCode(byte)).join('');             // ciphertext as string
  const ctBase64 = btoa(ctStr);                                                      // encode ciphertext as base64

  const ivHex = Array.from(iv).map(b => ('00' + b.toString(16)).slice(-2)).join(''); // iv as hex string

  return ivHex+ctBase64;                                                             // return iv+ciphertext
  
}

/**
 * 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 aesCtrDecrypt(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 iv = ciphertext.slice(0,32).match(/.{2}/g).map(byte => parseInt(byte, 16));  // get iv from ciphertext
    console.log('iv', iv);
  
    const alg = { name: 'AES-CTR', counter: new Uint8Array(iv), length: 128 };         // specify algorithm to use

    const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']); // use pw to generate key

    console.log('ciphertext.slice(32)', ciphertext.slice(32));
  
    const ctStr = atob(ciphertext.slice(32));                                          // decode base64 ciphertext
    const ctUint8 = new Uint8Array(ctStr.match(/\s\S/g).map(ch => ch.charCodeAt(0)));  // ciphertext as Uint8Array
    // note: why doesn't ctUint8 = new TextEncoder().encode(ctStr) work?

    const plainBuffer = await crypto.subtle.decrypt(alg, key, ctUint8);                // decrypt ciphertext using key
    const plaintext = new TextDecoder().decode(plainBuffer);                           // decode password from UTF-8

    return plaintext;                                                                  // return the plaintext
}

async function run() {
  const plaintext = "This my secret message";
  console.log(plaintext);
  
  const password = crypto.getRandomValues(new Uint8Array(32));
  console.log('password', password);

  let ciphertext = await aesCtrEncrypt(plaintext, password);
  console.log("ciphertext", ciphertext);
  
  // Decrypt
  let decryptedText = await aesCtrDecrypt(ciphertext, password);
  console.log('decryptedText', decryptedText);
  
}

run();

After I fix this I'll need to add a SHA2 (512bit) HMAC to authenticate the ciphertext.

@lolotobg
Copy link

lolotobg commented May 17, 2018

I think there is a problem with the regex here: ctStr.match(/\s\S/g), shouldn't this be ctStr.match(/[\s\S]/g) instead?
I mean we are trying to match every single character right? Not just the cases where a whitespace character is exactly followed by a non-whitespace one.

@Toxiapo
Copy link

Toxiapo commented Jul 12, 2018

@lolotobg

The new regex pattern fixed the problem I had with the gist.

@sohrabsaran
Copy link

sohrabsaran commented Jul 30, 2018

The code uses SHA-256 to create a hash from the password. If we look at how 7-zip etc. works, it takes the user-given password and hashes it several thousand times to increase the time it will take an attacker to use brute force to guess the user-given password. On further reading, it looks like you cannot just do the hashing just like that- you need to preserve the 'entropy' of the original user-given password. There are algorithms such as PBKDF2 and Argon. See https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey

@chrisveness
Copy link
Author

chrisveness commented Oct 9, 2018

Regexp updated. Oops!

@chrisveness
Copy link
Author

chrisveness commented Oct 9, 2018

@sohrabsaran I don't know anything about how 7-zip etc work. I believe repeatedly hashing a password does not necessarily increase entropy; for storing passwords for e.g. interactive logins, key derivation functions such as bcrypt or scrypt are a better solution. Correct me if I am wrong, but I think using a password to obtain a key for AES encryption is an entirely different beast. But in any case, you are welcome to improve the security, I just wanted to illustrate the use of the SubtleCrypto interface of the Web Cryptography API, as it is not necessarily immediately evident how to string the different elements together, from documents I found.

@MasterJames
Copy link

MasterJames commented Jan 17, 2019

It seems nodejs has replaced the crypto package with a dud=blank and their's included is not compatible, which ultimately led me here.
The goal today would be to get the decrypt working with the included/trusted crypto library (on the server side).
The decryption on the server side runs into obvious problems.
Firstly 'digest' on crypto is undefined so maybe for that line this could be right but it looks wrong being random that is unclear.
Maybe this is backward or somehow totally wrong it is subtle/brutal first step that doesn't crash and looks wrigt at first glance.

//let pwHash = await crypto.digest( 'SHA-256', pwUtf8 );
let pwHash = crypto.createHmac( 'sha256', pwUtf8 ).update( cyfrText ).digest( 'hex' );

Well I guess I'm asking is there a simple update to modernize this great helpful example to show compatible node server side methods with the nodejs crypto https://nodejs.org/api/crypto.html ?
Or maybe a new better example we can append as an amendment or alt-thread link here to help point people (myself included of course) into a better clearer modernized solution for server to browser/client encrypted communication.
[I'll of course move forward on my own currently trying to hack this example in these ways.]

Note: it seems to find TextEncoder() without any linked library, (why it requires 'new' seems wrong to me.)
Also node-webcrypto-ossl says it's not been reviewed and therefore Not safe for production.
I just noticed it's got a really cool KeyStore too it seems! https://www.npmjs.com/package/node-webcrypto-ossl

Maybe the comment about using PBKDF2 is better if one is moving forward? And on node 11.6 crypto importKey also comes up undefined not found.

@MasterJames
Copy link

MasterJames commented Jan 23, 2019

Okay well my apologies for being worked up about this. After much deliberation and frustration I have documented my current suggestions and thoughts to hopefully help bring clarity to others.
It seems difficult to find a good more current example of both sides of the problem Server & Browser working together efficiently.

In the link below I merely suggest to NodeJS Documentation that they consider putting an example of what matches up on the browser side with their new improved crypto in NodeJS 11.7 (which stomped the npm package I had before with a dumby blank).
nodejs/node#25589

It's really the browser SubtleCrypto that could be encrypting a string if passed and not need ArrayBuffers etc. but it's also less node dependent as node is browser related (for it to be more up to them to document it).

@mbaer3000
Copy link

mbaer3000 commented Jan 13, 2020

Lines 7 and 42 should be * @returns {Promise<String>} …

@tre-dev
Copy link

tre-dev commented Jul 27, 2020

Not sure if it's relevant, but received the typescript warning that ctStr.match(/[\s\S]/g) returns potentially null

Changed it to

const ctUint8 = new Uint8Array(
    (ctStr.match(/[\s\S]/g) || []).map((ch) => ch.charCodeAt(0)),
  )

Same a bit further up

const matched = ciphertext.slice(0, 24).match(/.{2}/g) || []
const iv = matched.map((byte) => parseInt(byte, 16)) // get iv from ciphertext

@dipasqualew
Copy link

dipasqualew commented Sep 24, 2020

Not sure if it's relevant, but received the typescript warning that ctStr.match(/[\s\S]/g) returns potentially null

Changed it to

const ctUint8 = new Uint8Array(
    (ctStr.match(/[\s\S]/g) || []).map((ch) => ch.charCodeAt(0)),
  )

Same a bit further up

const matched = ciphertext.slice(0, 24).match(/.{2}/g) || []
const iv = matched.map((byte) => parseInt(byte, 16)) // get iv from ciphertext

I heartily recommend throwing an error instead of providing a default. Please refer to my implementation (inspired by this gist):

https://github.com/dipasqualew/keylocal/blob/master/src/strategy/web/aes-gcp.ts

  /**
   * Decrypts an encrypted string using AES-GCM
   * with the supplied password
   *
   * @param context
   * @param encrypted
   */
  async decrypt(context: WebCryptoAesGcpContext, encrypted: string): Promise<string> {
    const ivSlice = encrypted.slice(0, 24).match(/.{2}/g);

    if (!ivSlice) {
      throw new Error('Could not extract IV from encrypted string');
    }

    const iv =  new Uint8Array(ivSlice.map((byte) => parseInt(byte, 16)));

    const alg = this.getAlgorithm(iv);
    const key = await this.getKey(alg, context.password, [KeyUsages.DECRYPT]);

    const decoded = atob(encrypted.slice(24)).match(/[\s\S]/g);

    if (!decoded) {
      throw new Error('Could not decode encrypted string');
    }

    // ciphertext as Uint8Array
    // note: why doesn't ctUint8 = new TextEncoder().encode(ctStr) work?
    const bytes = new Uint8Array(decoded.map((char) => char.charCodeAt(0)));

    // decrypt ciphertext using key
    const buffer = await this.crypto.subtle.decrypt(alg, key, bytes);

    // decode password from UTF-8
    const decrypted = this.decoder.decode(buffer);

    return decrypted;
  }

@rpivo
Copy link

rpivo commented Mar 20, 2021

@chrisveness thanks so much for this.

What is the purpose of slicing at ('00' + b.toString(16)).slice(-2)?

const arr  = [212, 242, 255, 184, 247, 93, 182, 42, 148, 68, 242, 103]

arr.map(b => ('00' + b.toString(16)).slice(-2))
// returns ["d4", "f2", "ff", "b8", "f7", "5d", "b6", "2a", "94", "44", "f2", "67"]

arr.map(b => b.toString(16))
// returns ["d4", "f2", "ff", "b8", "f7", "5d", "b6", "2a", "94", "44", "f2", "67"]

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.

@rpivo
Copy link

rpivo commented Mar 23, 2021

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

@jeffro256
Copy link

jeffro256 commented Sep 30, 2021

@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

@rodu
Copy link

rodu commented Nov 10, 2021

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.

@korywka
Copy link

korywka commented Jan 14, 2022

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment