Since GCM is based on CTR, decryption with CTR is in principle also possible. However, this should generally not be done in practice, since it skips the authentication of the ciphertext, which is the added value of GCM over CTR.
The correct way is to decrypt on the NodeJS side with GCM and properly consider the authentication tag.
The authentication tag is automatically appended to the ciphertext by the WebCrypto API, while the crypto module of NodeJS handles ciphertext and tag separately. Therefore, not only the nonce but also the authentication tag must be separated on the NodeJS side.
The following JavaScript/WebCrypto code demonstrates the encryption:
(async () => {
var nonce = crypto.getRandomValues(new Uint8Array(12));
var plaintext = 'The quick brown fox jumps over the lazy dog';
var plaintextEncoded = new TextEncoder().encode(plaintext);
var aesKey = base64ToArrayBuffer('a068Sk+PXECrysAIN+fEGDzMQ3xlpWgE1bWXHVLb0AQ=');
var aesCryptoKey = await crypto.subtle.importKey('raw', aesKey, 'AES-GCM', true, ['encrypt', 'decrypt']);
var ciphertextTag = await crypto.subtle.encrypt({name: 'AES-GCM', iv: nonce}, aesCryptoKey, plaintextEncoded);
ciphertextTag = new Uint8Array(ciphertextTag);
var nonceCiphertextTag = new Uint8Array(nonce.length + ciphertextTag.length);
nonceCiphertextTag.set(nonce);
nonceCiphertextTag.set(ciphertextTag, nonce.length);
nonceCiphertextTag = arrayBufferToBase64(nonceCiphertextTag.buffer);
document.getElementById("nonceCiphertextTag").innerHTML = nonceCiphertextTag; // ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=
})();
// Helper
// https://stackoverflow.com/a/9458996/9014097
function arrayBufferToBase64(buffer){
var binary = '';
var bytes = new Uint8Array(buffer);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
// https://stackoverflow.com/a/21797381/9014097
function base64ToArrayBuffer(base64) {
var binary_string = window.atob(base64);
var len = binary_string.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
return bytes.buffer;
}
<p style="font-family:'Courier New', monospace;" id="nonceCiphertextTag"></p>
This code is basically the same as your code, with some changes needed because of methods you didn't post like generateAesKey()
or arrayBufferToBase64()
.
Example output:
ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=
The following NodeJS/crypto code demonstrates the decryption. Note the tag separation and explicit passing with setAuthTag()
:
var crypto = require('crypto');
function decrypt(key, nonceCiphertextTag) {
key = Buffer.from(key, 'base64');
nonceCiphertextTag = Buffer.from(nonceCiphertextTag, 'base64');
var nonce = nonceCiphertextTag.slice(0, 12);
var ciphertext = nonceCiphertextTag.slice(12, -16);
var tag = nonceCiphertextTag.slice(-16); // Separate tag!
var decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag); // Set tag!
var decrypted = decipher.update(ciphertext, '', 'utf8') + decipher.final('utf8');
return decrypted;
}
var nonceCiphertextTag = 'ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=';
var key = 'a068Sk+PXECrysAIN+fEGDzMQ3xlpWgE1bWXHVLb0AQ=';
var decrypted = decrypt(key, nonceCiphertextTag);
console.log(decrypted);
Output:
The quick brown fox jumps over the lazy dog
For completeness: Decryption of a GCM ciphertext with CTR is also possible by appending 4 bytes to the 12 bytes nonce (0x00000002). For other nonce sizes the relation is more complex, see e.g. Relationship between AES GCM and AES CTR. However, as already said, this should not be done in practice, since it bypasses the authentication of the ciphertext and is thus insecure.