Reputation: 55
I am trying to decrypt a compact JWE
formatted response message with the crypto.subtle
libraries.
I am sending to the Server my public key in JWK format
with curve algo ECDH-ES+A128KW
, encryption A256GCM
, curve name P-256
.
The server sends me back a compact JWE
response.
As I understand this flow, it should be something like:
compact JWE
messageAES 128 KW
key based on servers public key and own private keyAES 128 GCM
key using the shared AES 128 KW
keyAES 128 GCM
key.When my code reaches the unwrapKey
step, i am only getting the error The operation failed for an operation-specific reason. At the moment I fail to find the problem.
My code looks like this right now:
export const decryptCompactJWE = async (
compactJWE: string,
privateKey: CryptoKey
) => {
const [protectedHeader, encryptedKey, iv, ciphertext, tag] =
compactJWE.split(".");
const header = JSON.parse(Buffer.from(protectedHeader, "base64").toString());
console.log("header:", header);
const publicKey = await crypto.subtle.importKey(
"jwk",
header.epk,
{
name: "ECDH",
namedCurve: "P-256",
},
true,
["deriveKey", "deriveBits"]
);
const derivedKey = await crypto.subtle.deriveKey(
{ name: "ECDH", public: publicKey },
privateKey,
{ name: "AES-KW", length: 128 },
true,
["unwrapKey"]
);
const myJWK = await crypto.subtle.exportKey("jwk", derivedKey);
console.log("jwk", myJWK);
const myAESKey = await crypto.subtle.unwrapKey(
"raw",
Buffer.from(encryptedKey, "base64url"),
derivedKey,
"AES-KW",
{ name: "AES-GCM" },
false,
["decrypt"]
);
console.log(myAESKey);
return crypto.subtle.decrypt(
{ name: "AES-GCM", iv: Buffer.from(iv, "base64url") },
myAESKey,
Buffer.from(ciphertext, "base64url")
);
};
Here is my test data:
const privateKey = {
kty: "EC",
crv: "P-256",
ext: true,
key_ops: ["deriveKey", "deriveBits"],
d: "vPZxnkg-j1xZ_8BZfH6jIvV52NvG2pxsZhmYgI9BEec",
x: "CorZZG9qa5korQ6eVLenbFz2QyGKkpoEYlAJxF1JzGA",
y: "yIEnQSGlMNVp6JEzZO3QvjQ0UDAwepzUZqwgsv0OTQE",
};
const JWE_RESPONSE = "eyJhbGciOiJFQ0RILUVTK0ExMjhLVyIsImVuYyI6IkExMjhHQ00iLCJraWQiOiJhYmMxMjMiLCJlcGsiOnsia3R5IjoiRUMiLCJ4IjoiNmNReW1GUlJSTjVkVHdoOHA5dWx1NkgwS3paSkRGcm4xdjFKb2NzVURCUSIsInkiOiJTSGliQjFEMnBHMmVMbUxMV09HTTB4UUtCRDFpM3ZtZjJRNjZIM2RnbzJ3IiwiY3J2IjoiUC0yNTYifX0.OwriqBm-PXkIj_QwbqKZRVxql0sja2-p.UrZs5Ixu_rFCxpCw.z9Rfhw.m6AgqKsttsp9TV2dREgbWw";
So far I looked up a all examples I could find to implement this and based on those it kinda looks okay. The debugger is not stepping into the native crypto.subtle code and the error message is also not telling much about what is going wrong for me. The existing examples I found so far, are mostly less complex and skip the key derive part.
Upvotes: 1
Views: 754
Reputation: 49341
WebCrypto is a low level API that in particular does not support JWT/JWS/JWE, so decrypting the token with WebCrypto alone means a corresponding effort, since some functionalities have to be implemented by yourself.
According to the header, the token is encrypted with:
alg: "ECDH-ES+A128KW"
enc: "A128GCM"
Here ECDH-ES+A128KW means that a shared secret is derived with ECDH, from which a wrapping key is determined using Concat KDF. With this key the encrypted key is unwrapped using AES-KW. Finally, the unwrapped key is applied to decrypt the content using AES-128/GCM, see here.
In the posted code Concat KDF is not taken into account. This and some other issues are the reason why decryption fails. Since WebCrypto does not support Concat KDF, a custom implementation is needed (or an additional library), which affects the whole implementation.
The following changes and fixes are required in the individual processing steps:
One of the inputs to Concat KDF is the shared secret. First, the private and the public key involved are imported. Then the shared secret can be determined most efficiently with deriveBits()
.
The gives as shared secret (hex encoded):
832bb9a5ac5c1b7febc64ed9522aefedd9f5d62830972224b1226e5498a6d13a
Keep in mind here:
(async () => {
// input data: encrypted token and private JWK
const compactJWE = "eyJhbGciOiJFQ0RILUVTK0ExMjhLVyIsImVuYyI6IkExMjhHQ00iLCJraWQiOiJhYmMxMjMiLCJlcGsiOnsia3R5IjoiRUMiLCJ4IjoiNmNReW1GUlJSTjVkVHdoOHA5dWx1NkgwS3paSkRGcm4xdjFKb2NzVURCUSIsInkiOiJTSGliQjFEMnBHMmVMbUxMV09HTTB4UUtCRDFpM3ZtZjJRNjZIM2RnbzJ3IiwiY3J2IjoiUC0yNTYifX0.OwriqBm-PXkIj_QwbqKZRVxql0sja2-p.UrZs5Ixu_rFCxpCw.z9Rfhw.m6AgqKsttsp9TV2dREgbWw";
const privateKey = {
kty: "EC",
crv: "P-256",
ext: true,
key_ops: ["deriveKey", "deriveBits"],
d: "vPZxnkg-j1xZ_8BZfH6jIvV52NvG2pxsZhmYgI9BEec",
x: "CorZZG9qa5korQ6eVLenbFz2QyGKkpoEYlAJxF1JzGA",
y: "yIEnQSGlMNVp6JEzZO3QvjQ0UDAwepzUZqwgsv0OTQE",
};
const [protectedHeader, encryptedKey, iv, ciphertext, tag] = compactJWE.split(".");
// import private key and public key (header.epk)
const privateCryptoKey = await crypto.subtle.importKey(
"jwk",
privateKey,
{name: "ECDH", namedCurve: "P-256"},
false,
["deriveBits"]
);
const decoder = new TextDecoder();
const header = JSON.parse(decoder.decode(b64url2ab(protectedHeader)));
const publicCryptoKey = await crypto.subtle.importKey(
"jwk",
header.epk,
{name: "ECDH", namedCurve: "P-256"},
false,
[]
);
// ECDH: derive shared secret (size: 32 bytes for P-256)
const sharedSecret = await crypto.subtle.deriveBits(
{ name: "ECDH", public: publicCryptoKey },
privateCryptoKey,
256
);
console.log("ECDH - shared secret: " + ab2hex(sharedSecret)); // ECDH - shared secret: 832bb9a5ac5c1b7febc64ed9522aefedd9f5d62830972224b1226e5498a6d13a
})();
// Helper -------------------------------------------------------------------------------------------------------
function b64url2ab(base64_string){
base64_string = base64_string.replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(window.atob(base64_string), c => c.charCodeAt(0));
}
function ab2hex(ab) {
return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
}
From the shared secret the 16 bytes wrapping key can now be derived with Concat KDF. Concat KDF is described in Section 5.8.1 of NIST.800-56A. A JavaScript implementation can be found e.g. here.
Concat KDF has a number of other input data in addition to the shared secret, which are described here and illustrated here with an example. These are:
This gives as wrapping key (hex encoded):
64c845c913d6a61208464a087ce72b81
(async () => {
const sharedSecret = hex2ab("832bb9a5ac5c1b7febc64ed9522aefedd9f5d62830972224b1226e5498a6d13a").buffer;
const encoder = new TextEncoder();
const algorithm = encoder.encode('ECDH-ES+A128KW'); // from header.alg
const keyLength = 128;
const apu = '';
const apv = '';
const otherInfo = concat(
lengthAndInput(algorithm),
lengthAndInput(apu),
lengthAndInput(apv),
uint32be(keyLength),
);
const wrappingKey = await concatKdf(new Uint8Array(sharedSecret), keyLength, otherInfo);
console.log("Concat KDF - wrapping key: " + ab2hex(wrappingKey)); // Concat KDF - wrapping key: 64c845c913d6a61208464a087ce72b81
})();
// Concat KDF implementation -------------------------------------------------------------------------------------------------------
function writeUInt32BE(buf, value, offset) {
buf.set([value >>> 24, value >>> 16, value >>> 8, value & 0xff], offset);
}
function uint32be(value) {
const buf = new Uint8Array(4);
writeUInt32BE(buf, value);
return buf;
}
async function concatKdf(secret, bits, value) {
const iterations = Math.ceil((bits >> 3) / 32);
const res = new Uint8Array(iterations * 32);
for (let iter = 0; iter < iterations; iter++) {
const buf = new Uint8Array(4 + secret.length + value.length);
buf.set(uint32be(iter + 1));
buf.set(secret, 4);
buf.set(value, 4 + secret.length);
res.set(Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', buf))), iter * 32);
}
return res.slice(0, bits >> 3);
}
function concat(...buffers) {
const size = buffers.reduce((acc, { length }) => acc + length, 0);
const buf = new Uint8Array(size);
let i = 0;
buffers.forEach((buffer) => {
buf.set(buffer, i);
i += buffer.length;
});
return buf;
}
function lengthAndInput(input) {
return concat(uint32be(input.length), input);
}
// Helper -------------------------------------------------------------------------------------------------------
function hex2ab(hex){
return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)}));
}
function ab2hex(ab) {
return Array.prototype.map.call(new Uint8Array(ab), x => ('00' + x.toString(16)).slice(-2)).join('');
}
Note that aside from the sample data in this question, the Concat KDF implementation adapted for above code has not been tested further!
After importing the wrapping key, the encrypted key can be unwrapped (AES-KW). With the unwrapped key the ciphertext can be decrypted (AES-128, GCM).
The gives as decrypted data (UTF-8 decoded):
8807
Note regarding AES/GCM that:
(async () => {
const wrappingKey = hex2ab("64c845c913d6a61208464a087ce72b81").buffer;
// input data: encrypted token and private JWK
const compactJWE = "eyJhbGciOiJFQ0RILUVTK0ExMjhLVyIsImVuYyI6IkExMjhHQ00iLCJraWQiOiJhYmMxMjMiLCJlcGsiOnsia3R5IjoiRUMiLCJ4IjoiNmNReW1GUlJSTjVkVHdoOHA5dWx1NkgwS3paSkRGcm4xdjFKb2NzVURCUSIsInkiOiJTSGliQjFEMnBHMmVMbUxMV09HTTB4UUtCRDFpM3ZtZjJRNjZIM2RnbzJ3IiwiY3J2IjoiUC0yNTYifX0.OwriqBm-PXkIj_QwbqKZRVxql0sja2-p.UrZs5Ixu_rFCxpCw.z9Rfhw.m6AgqKsttsp9TV2dREgbWw";
const [protectedHeader, encryptedKey, iv, ciphertext, tag] = compactJWE.split(".");
// Import wrapping key, decrypt wrapped key:
const wrappingCryptoKey = await crypto.subtle.importKey(
"raw",
wrappingKey,
"AES-KW",
false,
["unwrapKey"]
);
const unwrappedCryptoKey = await crypto.subtle.unwrapKey(
"raw",
b64url2ab(encryptedKey),
wrappingCryptoKey,
"AES-KW",
{ name: "AES-GCM" },
false,
["decrypt"]
);
// Decrypt ciphertext
// - Concatenate ciphertext and tag: ciphertext|tag
// - Consider header as AAD
const encoder = new TextEncoder();
const ciphertextAB = new Uint8Array(b64url2ab(ciphertext));
const tagAB = new Uint8Array(b64url2ab(tag));
const ciphertextTag = new Uint8Array(ciphertextAB.length + tagAB.length);
ciphertextTag.set(ciphertextAB);
ciphertextTag.set(tagAB, ciphertextAB.length);
const additionalData = encoder.encode(protectedHeader);
const decryptedText = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64url2ab(iv), additionalData: additionalData },
unwrappedCryptoKey,
ciphertextTag
);
const decoder = new TextDecoder();
console.log("Decrypted text: " + decoder.decode(decryptedText)); // Decrypted text: 8807
})();
// Helper -------------------------------------------------------------------------------------------------------
function hex2ab(hex){
return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)}));
}
function b64url2ab(base64_string){
base64_string = base64_string.replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(window.atob(base64_string), c => c.charCodeAt(0));
}
All together:
(async () => {
// input data: encrypted token and private JWK
const compactJWE = "eyJhbGciOiJFQ0RILUVTK0ExMjhLVyIsImVuYyI6IkExMjhHQ00iLCJraWQiOiJhYmMxMjMiLCJlcGsiOnsia3R5IjoiRUMiLCJ4IjoiNmNReW1GUlJSTjVkVHdoOHA5dWx1NkgwS3paSkRGcm4xdjFKb2NzVURCUSIsInkiOiJTSGliQjFEMnBHMmVMbUxMV09HTTB4UUtCRDFpM3ZtZjJRNjZIM2RnbzJ3IiwiY3J2IjoiUC0yNTYifX0.OwriqBm-PXkIj_QwbqKZRVxql0sja2-p.UrZs5Ixu_rFCxpCw.z9Rfhw.m6AgqKsttsp9TV2dREgbWw";
const privateKey = {
kty: "EC",
crv: "P-256",
ext: true,
key_ops: ["deriveKey", "deriveBits"],
d: "vPZxnkg-j1xZ_8BZfH6jIvV52NvG2pxsZhmYgI9BEec",
x: "CorZZG9qa5korQ6eVLenbFz2QyGKkpoEYlAJxF1JzGA",
y: "yIEnQSGlMNVp6JEzZO3QvjQ0UDAwepzUZqwgsv0OTQE",
};
const [protectedHeader, encryptedKey, iv, ciphertext, tag] = compactJWE.split(".");
// import private key and public key (header.epk)
const privateCryptoKey = await crypto.subtle.importKey(
"jwk",
privateKey,
{name: "ECDH", namedCurve: "P-256"},
false,
["deriveBits"]
);
const decoder = new TextDecoder();
const header = JSON.parse(decoder.decode(b64url2ab(protectedHeader)));
const publicCryptoKey = await crypto.subtle.importKey(
"jwk",
header.epk,
{name: "ECDH", namedCurve: "P-256"},
false,
[]
);
// ECDH: derive shared secret (size: 32 bytes for P-256)
const sharedSecret = await crypto.subtle.deriveBits(
{ name: "ECDH", public: publicCryptoKey },
privateCryptoKey,
256
);
// Concat KDF: determine wrapping key
const encoder = new TextEncoder();
const algorithm = encoder.encode('ECDH-ES+A128KW'); // from header.alg
const keyLength = 128;
const apu = '';
const apv = '';
const otherInfo = concat(
lengthAndInput(algorithm),
lengthAndInput(apu),
lengthAndInput(apv),
uint32be(keyLength),
);
const wrappingKey = await concatKdf(new Uint8Array(sharedSecret), keyLength, otherInfo);
// import wrapping key, decrypt wrapped key:
const wrappingCryptoKey = await crypto.subtle.importKey(
"raw",
wrappingKey,
"AES-KW",
false,
["unwrapKey"]
);
const unwrappedCryptoKey = await crypto.subtle.unwrapKey(
"raw",
b64url2ab(encryptedKey),
wrappingCryptoKey,
"AES-KW",
{ name: "AES-GCM" },
false,
["decrypt"]
);
// decrypt ciphertext
// - Concatenate ciphertext and tag: ciphertext|tag
// - Consider header as AAD
const ciphertextAB = new Uint8Array(b64url2ab(ciphertext));
const tagAB = new Uint8Array(b64url2ab(tag));
const ciphertextTag = new Uint8Array(ciphertextAB.length + tagAB.length);
ciphertextTag.set(ciphertextAB);
ciphertextTag.set(tagAB, ciphertextAB.length);
const additionalData = encoder.encode(protectedHeader);
const decryptedText = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64url2ab(iv), additionalData: additionalData },
unwrappedCryptoKey,
ciphertextTag
);
console.log("Decrypted text: " + decoder.decode(decryptedText)); // Decrypted text: 8807
})();
// Concat KDF implementation -------------------------------------------------------------------------------------------------------
function writeUInt32BE(buf, value, offset) {
buf.set([value >>> 24, value >>> 16, value >>> 8, value & 0xff], offset);
}
function uint32be(value) {
const buf = new Uint8Array(4);
writeUInt32BE(buf, value);
return buf;
}
async function concatKdf(secret, bits, value) {
const iterations = Math.ceil((bits >> 3) / 32);
const res = new Uint8Array(iterations * 32);
for (let iter = 0; iter < iterations; iter++) {
const buf = new Uint8Array(4 + secret.length + value.length);
buf.set(uint32be(iter + 1));
buf.set(secret, 4);
buf.set(value, 4 + secret.length);
res.set(Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', buf))), iter * 32);
}
return res.slice(0, bits >> 3);
}
function concat(...buffers) {
const size = buffers.reduce((acc, { length }) => acc + length, 0);
const buf = new Uint8Array(size);
let i = 0;
buffers.forEach((buffer) => {
buf.set(buffer, i);
i += buffer.length;
});
return buf;
}
function lengthAndInput(input) {
return concat(uint32be(input.length), input);
}
// Helper -------------------------------------------------------------------------------------------------------
function b64url2ab(base64_string){
base64_string = base64_string.replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(window.atob(base64_string), c => c.charCodeAt(0));
}
Upvotes: 2