This is a follow-up question to a previous one, trying to get a working decryption of NDEF URLs from an NXP NTAG424 tag according to this documentation from NXP (4.4.2.1).
I've put together an initial WebCrypto code based on @Topaco 's excellent suggestions and the code now succesfuly decodes the picc
and enc
from the response and verifies the CMAC.
However, this only works for some payloads, while others still throw a bad decrypt
error. Iv'e isolated the issue to the decryption of the enc
static data payload using the derived keys from sv1
, search for encDataFull
in the reference code.
The complete implementation follows a reference in Python which is also the backend for the online validation service I refer to later on: https://github.com/nfc-developer/sdm-backend/blob/master/libsdm/sdm.py
The only difference is the use of AES-CBC
vs. AES-ECB
in encoding the encIVFull
, since AES-ECB
is not supported by WebCrypto.
Following this post, I understood that an AES-CBC
with an IV of 0s would be equal to AES-ECB
since only the first block in needed:
const encIV = Buffer.from(encIVFull).subarray(0, 16)
Reference for ECB / CBC being the same for IV of zeros: CBC Example ECB Example
some payloads work and some fail. This is despite the fact that they all pass verification on an online utility (provided as well)
Working payload (link to online validation):
// https://sdm.nfcdeveloper.com/tagtt?_____TRIAL_VERSION______NOT_FOR_PRODUCTION_____&picc_data=FDE4AFA99B5C820A2C1BB0F1C792D0EB&enc=94592FDE69FA06E8E3B6CA686A22842B&cmac=C48B89C17A233B2C
const KEY = '00000000000000000000000000000000'
const PICC = 'FDE4AFA99B5C820A2C1BB0F1C792D0EB'
const ENC = '94592FDE69FA06E8E3B6CA686A22842B'
const CMAC = 'C48B89C17A233B2C'
const CMAC_ARG = 'cmac'
returns
{"dataTag":"c7","uid":"04958CAA5C5E80","cnt":"010000","cntInt":1,"cmacPass":true,"data":"78787878787878787878787878787878"}
Failing payload (link to online validation):
// https://sdm.nfcdeveloper.com/tagtt?_____TRIAL_VERSION______NOT_FOR_PRODUCTION_____&picc_data=6107DD7607B179270EAFFCA2F0911940&enc=37C1E399E0948BEA54138F92DDD1E743&cmac=0406016621FC6AC6
const KEY = '00000000000000000000000000000000'
const PICC = '6107DD7607B179270EAFFCA2F0911940'
const ENC = '37C1E399E0948BEA54138F92DDD1E743'
const CMAC = '0406016621FC6AC6'
const CMAC_ARG = 'cmac'
Fails with bad decrypt
Another failing payload (link to online validation):
// https://sdm.nfcdeveloper.com/tagtt?_____TRIAL_VERSION______NOT_FOR_PRODUCTION_____&picc_data=FD91EC264309878BE6345CBE53BADF40&enc=CEE9A53E3E463EF1F459635736738962&cmac=ECC1E7F6C6C73BF6
const KEY = '00000000000000000000000000000000'
const PICC = 'FD91EC264309878BE6345CBE53BADF40'
const ENC = 'CEE9A53E3E463EF1F459635736738962'
const CMAC = 'ECC1E7F6C6C73BF6'
const CMAC_ARG = 'cmac'
Would appreciate any further insight on how to make the decryption code more robust.
The bug is an incorrect calculation of the fake padding block in the workaround. You are using the final IV as IV instead of the ENC
ciphertext, which is wrong.
The wrong IV results in a wrong padding block. When decrypting the entire ciphertext, this produces internally random-like bytes at the end of the plaintext, which generally correspond to an incompatible PKCS#7 padding (as a reminder: PKCS#7 is the padding used by WebCrypto and cannot be disabled).
This then triggers a bad decrypt
(as in your two failure cases). By chance, however, a compatible padding can also result so that no bad decrypt
is generated (as in your success case), but the data is generally still corrupted by the subsequent processing due to the incorrect (but compatible) padding bytes.
Below is a reminder of how the workaround from this post works: If you have a plaintext in which the last block pt_block_last
is completely filled, a complete block pt_padding
consisting of 16 0x10 values is automatically appended when (PKCS#7) padding is enabled, which is encrypted with AES/CBC, whereby ct_block_last
is used as IV. The latter is the case because CBC applies the ciphertext of the previous block ct_block_n-1
as IV when encrypting a plaintext block pt_block_n
, see CBC flowchart.
The workaround does exactly that explicitly: When encrypting an empty block, the enabled (PKCS#7) padding automatically generates a complete padding block consisting of 16 0x10 values. By using the last block of the ENC
ciphertext (or in the examples the entire ENC
ciphertext, as this is only 1 block in size) as IV, encryption generates a ciphertext block that contains the encrypted padding and matches the ENC
ciphertext.
The concatenation of ENC
and that ciphertext block produces a two-block ciphertext that can be decrypted with WebCrypto when padding is enabled.
Test:
Your implementation already generates the correct final IV and key for decryption using CMAC. I calculated these two values with your code and use them to test the fixed decryption code below:
(async () => {
async function decryptPayload(keyHex, ivHex, encHex) {
const key = hex2ab(keyHex, 'hex')
const iv = hex2ab(ivHex, 'hex')
const enc = hex2ab(encHex, 'hex')
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{
name: 'AES-CBC',
length: 128,
},
true,
['encrypt', 'decrypt']
)
const encPaddingBlock = await crypto.subtle.encrypt(
{
name: 'AES-CBC',
iv: enc.subarray(-16) // Fix: Apply enc
},
cryptoKey,
new Uint8Array()
)
const encPadded = concat(enc, new Uint8Array(encPaddingBlock))
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-CBC',
iv: iv,
},
cryptoKey,
encPadded
)
return decrypted
}
const decoder = new TextDecoder()
// 1
var ENC = '94592FDE69FA06E8E3B6CA686A22842B'
var IV_CMAC = '7b3f3cfc39d3b7ff5868636e38af7c3a'
var KEY_CMAC = '8097d73344d53f963b09e23e03b62336'
var decrypted = await(decryptPayload(KEY_CMAC, IV_CMAC, ENC))
console.log(ab2hex(decrypted))
console.log(decoder.decode(decrypted))
// 2
var ENC = '37C1E399E0948BEA54138F92DDD1E743'
var IV_CMAC = '771aed39fe43c881a9c616fb270be958'
var KEY_CMAC = 'bf3942b425b3b633146182f12d020999'
var decrypted = await(decryptPayload(KEY_CMAC, IV_CMAC, ENC));
console.log(ab2hex(decrypted))
console.log(decoder.decode(decrypted))
// 3
var ENC = 'CEE9A53E3E463EF1F459635736738962'
var IV_CMAC = '0ad3eb2717a58332cbc8899bbecbd411'
var KEY_CMAC = '42132d669442ad43e072c8c0c9828a72'
var decrypted = await(decryptPayload(KEY_CMAC, IV_CMAC, ENC));
console.log(ab2hex(decrypted))
console.log(decoder.decode(decrypted))
// helper
function concat(a, b) {
var c = new (a.constructor)(a.length + b.length);
c.set(a, 0);
c.set(b, a.length);
return c;
}
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('');
}
})();
All three ENC
ciphertexts are now correctly decrypted.