javascriptencryptionaeswebcrypto-apisubtlecrypto

Decrypting NTAG424 encrypted data fails sporadically with WebCrypto


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-ECBin 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.


Solution

  • 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.