Search code examples
node.jsencryptionaesrandom-accessnode-crypto

Is it possible to decipher at random position with nodejs crypto?


My understanding is that an AES block cipher in CTR mode allows, in theory, to decipher any location of a large file, without needing to read the whole file.

However, I don't see how to do this with nodejs crypto module. I could feed the Decipher.update method with dummy blocks until I get to the part I'm interested in, at which point I would feed actual data read from the file, but that would be an awful hack, inefficient, and fragile, since I need to be aware of the block size.

Is there a way to do it with the crypto module, and if not, what module can I use?


Solution

  • I've found different approaches to solve this problem:

    Method 1 : CTR mode

    This answer is based on @ArtjomB. and @gusto2 comments and answer, which really gave me the solution. However, here is a new answer with a working code sample, which also shows implementation details (for example the IV must be incremented as a Big Endian number).

    The idea is simple: to decrypt starting at an offset of n blocks, you just increment the IV by n. Each block is 16 bytes.

    import crypto = require('crypto');
    let key = crypto.randomBytes(16);
    let iv = crypto.randomBytes(16);
    
    let message = 'Hello world! This is test message, designed to be encrypted and then decrypted';
    let messageBytes = Buffer.from(message, 'utf8');
    console.log('       clear text: ' + message);
    
    let cipher = crypto.createCipheriv('aes-128-ctr', key, iv);
    let cipherText = cipher.update(messageBytes);
    cipherText = Buffer.concat([cipherText, cipher.final()]);
    
    // this is the interesting part: we just increment the IV, as if it was a big 128bits unsigned integer. The IV is now valid for decrypting block n°2, which corresponds to byte offset 32
    incrementIV(iv, 2); // set counter to 2
    
    let decipher = crypto.createDecipheriv('aes-128-ctr', key, iv);
    let decrypted = decipher.update(cipherText.slice(32)); // we slice the cipherText to start at byte 32
    decrypted = Buffer.concat([decrypted, decipher.final()]);
    let decryptedMessage = decrypted.toString('utf8');
    console.log('decrypted message: ' + decryptedMessage);
    

    This program will print:

           clear text: Hello world! This is test message, designed to be encrypted and then decrypted
    decrypted message: e, designed to be encrypted and then decrypted
    

    As expected, the decrypted message is shifted by 32 bytes.

    And finally, here is the incrementIV implementation:

    function incrementIV(iv: Buffer, increment: number) {
        if(iv.length !== 16) throw new Error('Only implemented for 16 bytes IV');
    
        const MAX_UINT32 = 0xFFFFFFFF;
        let incrementBig = ~~(increment / MAX_UINT32);
        let incrementLittle = (increment % MAX_UINT32) - incrementBig;
    
        // split the 128bits IV in 4 numbers, 32bits each
        let overflow = 0;
        for(let idx = 0; idx < 4; ++idx) {
            let num = iv.readUInt32BE(12 - idx*4);
    
            let inc = overflow;
            if(idx == 0) inc += incrementLittle;
            if(idx == 1) inc += incrementBig;
    
            num += inc;
    
            let numBig = ~~(num / MAX_UINT32);
            let numLittle = (num % MAX_UINT32) - numBig;
            overflow = numBig;
    
            iv.writeUInt32BE(numLittle, 12 - idx*4);
        }
    }
    

    Method 2 : CBC mode

    Since CBC uses the previous cipher text block as IV, and that all cipher text blocks are known during the decryption stage, you don't have anything particular to do, you can decrypt at any point of the stream. The only thing is that the first block you decrypt will be garbage, but the next ones will be fine. So you just need to start one block before the part you actually want to decrypt.