For a specific application I need to symmetrically encrypt on my .NET server and decrypt in the browser.
I'm generally free to choose the algorithm, so I tried AES-GCM as that has a better built-in API on .NET and is also supported by crypto.subtle.
I don't get it to work though, I'm stumped at getting an unhelpful exception from the call to crypto.subtle.decrypt
, which contains no message on Chrome and says "The operation failed for an operation-specific reason" on Firefox.
The decryption code is (also here in codesandbox):
import "./styles.css";
import { Base64 } from "js-base64";
let nonce = Base64.toUint8Array("o/YcD/yZVU2egcGd");
async function importKey() {
const keyData = Base64.toUint8Array("3NraMtQP10qKGL3HLloObA==");
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
true,
["decrypt", "encrypt"]
);
return key;
}
var cypherText = Base64.toUint8Array("Is+l7cojlfbuU3vUN0gWMw==");
async function decrypt() {
const key = await importKey();
try {
return await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
key,
cypherText
);
} catch (ex) {
console.error("Error: " + ex.message);
}
}
async function work() {
const decrypted = await decrypt();
const result = new TextDecoder().decode(decrypted);
document.getElementById("app").innerText = result;
}
work();
Not entirely sure if what .NET calls nonce is what JS calls iv.
In any case, the catch handler is always reached.
For comparison, the .NET code to generate the cypher text is (also here as a LINQPad query):
AesGcm.NonceByteSizes.Dump();
AesGcm.TagByteSizes.Dump();
var key = Guid.Parse("32dadadc-0fd4-4ad7-8a18-bdc72e5a0e6c")
.ToByteArray()
.ToArray();
var nonce = Guid.Parse("0f1cf6a3-99fc-4d55-9e81-c19d09003e9b")
.ToByteArray()
.Take(12)
.ToArray();
Convert.ToBase64String(key).Dump("key");
var aes = new AesGcm(key);
Convert.ToBase64String(nonce).Dump("nonce");
var text = Encoding.UTF8.GetBytes("Hello, world 123");
text.Length.Dump("cypher text size");
var buffer = new Byte[text.Length];
var tag = new Byte[16];
aes.Encrypt(nonce, text, buffer, tag, null);
String.Join(" ", from b in buffer select b.ToString("d")).Dump("cypher text");
Convert.ToBase64String(buffer).Dump("cypher text");
var text2 = new Byte[text.Length];
aes.Decrypt(nonce, buffer, tag, text2, null);
Encoding.UTF8.GetString(text2).Dump("check");
In the .NET code, ciphertext and tag are processed separately, while in the JavaScript code, both must be processed concatenated: ciphertext | tag
.
The authentication tag generated in the .NET code isn't applied in the JavaScript code at all, which alone prevents the decryption.
Furthermore, I can't reproduce the ciphertext used in the JavaScript code with the .NET code. Key and nonce, however, can be reproduced. When I run the .NET code I get the following data (Base64 encoded):
nonce: o/YcD/yZVU2egcGd
key: 3NraMtQP10qKGL3HLloObA==
ciphertext: 1dupqLQFLXe31Pq48udCFw==
tag: kfMFJS+cy4VoDuFX1t7Reg==
If the correct ciphertext is used in the JavaScript code, and ciphertext and tag are concatenated, then the decryption is successful:
// Concatenate ciphertext and tag!
const ciphertext = Base64.toUint8Array("1dupqLQFLXe31Pq48udCFw==");
const tag = Base64.toUint8Array("kfMFJS+cy4VoDuFX1t7Reg==");
const ciphertextTag = new Uint8Array(ciphertext.length + tag.length);
ciphertextTag.set(ciphertext);
ciphertextTag.set(tag, ciphertext.length);
let nonce = Base64.toUint8Array("o/YcD/yZVU2egcGd");
async function importKey() {
const keyData = Base64.toUint8Array("3NraMtQP10qKGL3HLloObA==");
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
true,
["decrypt", "encrypt"]
);
return key;
}
async function decrypt() {
const key = await importKey();
try {
return await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
key,
ciphertextTag // Use the concatenated data!
);
} catch (ex) {
console.error("Error: " + ex.message);
}
}
async function work() {
const decrypted = await decrypt();
const result = new TextDecoder().decode(decrypted);
console.log(result);
}
work();
<script src="https://cdn.jsdelivr.net/npm/js-base64@3.2.4/base64.min.js"></script>
Note that for security reasons a key / nonce pair may only be used once (see GCM / Security). Usually a fresh, random nonce is created for each encryption. Since the nonce isn't secret, it's usually placed before the ciphertext: nonce | ciphertext | tag
. This is sent to the recipient, who separates the nonce (and depending on the API, the tag) and thus has all the information needed for decryption.