2
votes

I am trying to create an application in NodeJS (Electron) as a cross platform Desktop App. This will pair with the mobile app developed using SWIFT on iOS. As part of sharing data, it is encrypted using AES-256-GCM algorithm. I have the following encrypt and decrypt methods in SWIFT:

//listItems is an array of the following structure:
// - id: Int, title: String, data: String, ldate: String
func encrypt(listItems: [ListItem], pass: String) -> String{
    let encoder = JSONEncoder()
    do{
        let data = try encoder.encode(listItems)
        let key = SymmetricKey(data: SHA256.hash(data: pass.data(using: .utf8)!))
        let iv = AES.GCM.Nonce()
        let sealedBox = try AES.GCM.seal(data, using: key, nonce: iv)
        return sealedBox.combined?.base64EncodedData()
    }catch{
        fatalError("Couldn't encrypt data\(error)")
    }
}

func decrypt(data: Data, pass: String) -> [ListItem]{
    do{
        let key = SymmetricKey(data: SHA256.hash(data: pass.data(using: .utf8)!))
        let mySealedBox = try AES.GCM.SealedBox(combined: Data(base64Encoded: data)!)
        let content = try AES.GCM.open(mySealedBox, using: key)
        return load(content)
    }catch{
        fatalError("Couldn't encrypt data\(error)")
    }
}

func load<T: Decodable>(_ data: Data) -> T{
    do{
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    }catch{
        fatalError("Could not parse the data")
    }
}

For NodeJS, I have the below functions:

const crypto = require('crypto')
module.exports = {
    encryptData(data,password){
        let password_hash = crypto.createHash('sha256').update(password, 'utf-8').digest('hex').slice(0,32).toLowerCase();
        let iv = crypto.pseudoRandomBytes(12);
        iv = Buffer.from('mBj0tzBUxDFmix1T', 'base64');

        let cipher = crypto.createCipheriv('aes-256-gcm', password_hash, iv);
        let encryptedData = Buffer.from(cipher.update(data, 'utf8', 'hex') + cipher.final('hex'), 'hex');
        console.log(' --------------- ENC BEGIN ---------------');
        console.log(`IV Length: ${iv.length}`);
        //console.log(`IV Base64 Length: ${iv.toString('base64').length}`);
        console.log(iv.toString('base64'));
        //console.log(`AuthTag Length: ${cipher.getAuthTag().length}`);
        //console.log(`AuthTag Base64 Length: ${cipher.getAuthTag().toString('base64').length}`);
        console.log(cipher.getAuthTag().toString('base64'));
        //console.log(`Encrypted Data Length: ${encryptedData.length}`)
        //console.log(`Encrypted Data Base64 Length: ${encryptedData.toString('base64').length}`)
        console.log(encryptedData.toString('base64'));
        console.log(' --------------- ENC END ---------------');
        console.log(Buffer.concat([cipher.getAuthTag(), encryptedData]).toString('base64'));
        console.log(Buffer.concat([encryptedData, cipher.getAuthTag()]).toString('base64'));
        //let encryptedBuffer = Buffer.concat([iv, cipher.getAuthTag(), encryptedData]);
        return iv.toString('base64') + cipher.getAuthTag().toString('base64') + encryptedData.toString('base64');
    },
    
    decryptData(data,password){
        let password_hash = crypto.createHash('sha256').update(password, 'utf-8').digest('hex').slice(0,32).toLowerCase();
        //let combinerBuffer = Buffer.from(data, 'base64');
        //let iv = combinerBuffer.slice(0,16);
        let iv = Buffer.from(data.slice(0,16), 'base64');
        console.log(' --------------- DEC BEGIN ---------------');
        console.log(iv.toString('base64'));
        let at = Buffer.from(data.slice(16,32), 'base64');
        console.log(at.toString('base64'));
        let enc_buffer = Buffer.from(data.slice(32), 'base64');
        console.log(enc_buffer.toString('base64'));
        console.log(' --------------- DEC END ---------------');
        let deciper = crypto.createDecipheriv('aes-256-gcm', password_hash, iv);
        deciper.setAuthTag(at)
        let dec_buf = deciper.update(enc_buffer, 'utf8') + deciper.final('utf8');
        return dec_buf.toString('utf8');
    }
}

The encrypted by SWIFT cannot be decrypted by NodeJS. While decrypting the data, i get the error: Unsupported state or unable to authenticate data

I have a similar code in Java also which works well with the SWIFT generated encrypted data but the NodeJS one does not work at all. The main question is how do I get the AAD and AuthTag from the combined encrypted text generated by SWIFT. In Java, I just need to extract the first 16 bytes of IV and the rest as Cipher Text which includes Auth Tag as well. However, in NodeJS i need to manually extract the pass on the AuthTag. I tried breaking up the combined data from SWIFT as:

  • IV (16 bytes) + CipherText + Tag (16 bytes) and also as:
  • IV (16 bytes) + Tag (16 bytes) + CipherText

Both of these do not work and generate the same error as above.

Below is the Java Code:

import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

class CryptoTest{
    public static void main(String[] args){
        try{
            String enc = Crypto.encrypt("This is data", "password");
            String dec = Crypto.decrypt(enc,"password");
            System.out.println(enc);
            System.out.println(dec);
        
        }catch(Exception ex){
            System.out.println("ERROR");
        }
    }    

    private static class Crypto {
        private static final int GCM_TAG_LENGTH = 16;
        private static final int GCM_IV_LENGTH = 12;
        private static final String ALGORITHM = "AES_256/GCM/NoPadding";
        private static final String ALGORITHM_SHORT_NAME = "AES";
        private static final String HASH_ALGORITHM = "SHA-256";
        
        public static String encrypt(String plaintext, String password) throws Exception
        {
            //Generate the key from password
            MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
            byte[] key = md.digest(password.getBytes(StandardCharsets.UTF_8));
            
            SecureRandom sr = new SecureRandom(password.getBytes(StandardCharsets.UTF_8));        
            byte[] iv = new byte[GCM_IV_LENGTH];
            // sr.nextBytes(iv);
            iv = Base64.getDecoder().decode("mBj0tzBUxDFmix1T");
    
            // Get Cipher Instance
            Cipher cipher = Cipher.getInstance(ALGORITHM);
    
            // Create SecretKeySpec
            SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM_SHORT_NAME);
    
            // Create GCMParameterSpec
            GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
    
            // Initialize Cipher for ENCRYPT_MODE
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
    
            // Perform Encryption
            byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
            
            //Return the IV and CipherText as Base64 encoded and appended strings
            System.out.println(Base64.getEncoder().encodeToString(iv));
            System.out.println(Base64.getEncoder().encodeToString(cipherText));

            return Base64.getEncoder().encodeToString(iv)+Base64.getEncoder().encodeToString(cipherText);
        }
    
        public static String decrypt(String sourceText, String password) throws Exception
        {
            //Get the IV from cipherText
            byte[] iv = Base64.getDecoder().decode(sourceText.substring(0,16));
            
            //Get the reminder of cipherText after the iv
            byte[] cipherText = Base64.getDecoder().decode(sourceText.substring(16));
            
            //Generate the key from password
            MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
            byte[] key = md.digest(password.getBytes(StandardCharsets.UTF_8));
            
            // Get Cipher Instance
            Cipher cipher = Cipher.getInstance(ALGORITHM);
    
            // Create SecretKeySpec
            SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM_SHORT_NAME);
    
            // Create GCMParameterSpec
            GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
    
            // Initialize Cipher for DECRYPT_MODE
            cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
    
            // Perform Decryption
            byte[] decryptedText = cipher.doFinal(cipherText);
    
            return new String(decryptedText);
        }
    }
}

Data: This is data

Key: password

IV: mBj0tzBUxDFmix1T

Java code generates the following:

Encrypted Text: UgNY3VAwNU07iEqU1Jq3m3Q+p6bDCZg6UI0h8w==

NodeJS code generates the following:

AuthTag: BXQ0vZH4HBBpGb7Y7R9iJw==

Encrypted Text: O+wuVJB06JO6rPrc

From what I have read, Java will generate the encrypted text with the AuthTag included in it. I tried concatenating the AuthTag to Encrypted Text but the output never equals the one generated by Java.

The Java encrypted text however can be decrypted by the SWIFT CryptoKit code without any issues.

1
Sharing the Java would help me a lot as I'm not familiar with Swift encryption (but with Java & NodeJs). An additional sample dataset would be of great help as well (plaintext, key & ciphertext) [hex or better Base64-encoded]. - Michael Fehr
@MichaelFehr I have updated the question with the Java code and a sample data set. I have also updated the NodeJS code as i found problem while reviewing it along with the Java code but still the outputs from both the codes are completely different. - Amit Bajaj
Just a quick shot (today morning I'm busy): On Java side the AuthTag is implicitly added to the ciphertext so you should change your NodeJs method from cipher.getAuthTag().toString('base64') + encryptedData.toString('base64') to something like [pseudo code] "(encryptedData + cipher.getAuthTag()).toString('base64')" (means you have to concatenate the bytes from ciphertext | AuthTag and then encode it to base64. - Michael Fehr
The concatenation of cipherText and authTag Buffer.concat([encryptedData, cipher.getAuthTag()]).toString('base64') returns O+wuVJB06JO6rPrcBXQ0vZH4HBBpGb7Y7R9iJw== whereas the Java program returns UgNY3VAwNU07iEqU1Jq3m3Q+p6bDCZg6UI0h8w==. Both of these use the same IV and KEY so ideally both should return the same encrypted text. - Amit Bajaj

1 Answers

5
votes

In addition to the fact that Java crypto puts the GCM authtag at the end of the ciphertext (as identified in comments) your Java code is using the SHA256 of the password as the key, while your nodejs is using the ASCII characters of the hex representation of half the SHA256; this is a completely different value, and the whole point of symmetric (traditional) cryptography is that you must use (exactly) the same key at both ends. In addition your method of converting to base64 and then concatenating with string-plus, and conversely slicing the base64 before decoding, only works right if the data and IV are both multiples of 3: the GCM IV/nonce is 12, which is okay, and so is your example value 'This is data', but most real data won't be.

The following modified js matches your Java. I don't do SWIFT, but if as you say it matches your Java it should also match this js.

const crypto = require('crypto')
function encryptData(data,password){
        //--let password_hash = crypto.createHash('sha256').update(password, 'utf-8').digest('hex').slice(0,32).toLowerCase();
        let password_hash = crypto.createHash('sha256').update(password, 'utf-8').digest();
        let iv = Buffer.from('mBj0tzBUxDFmix1T', 'base64'); // TEST ONLY SHOULD BE UNIQUE (such as random) 
        let cipher = crypto.createCipheriv('aes-256-gcm', password_hash, iv);
        //--let encryptedData = Buffer.from(cipher.update(data, 'utf8', 'hex') + cipher.final('hex'), 'hex');
        let encryptedData = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
        //--return iv.toString('base64') + cipher.getAuthTag().toString('base64') + encryptedData.toString('base64');
        return Buffer.concat([iv,encryptedData,cipher.getAuthTag()]).toString('base64');
        // or just concat([iv,cipher.update(data,'utf8'),cipher.final(),cipher.getAuthTag()]).toString('base64') 
    }
    
function decryptData(data,password){
        let password_hash = crypto.createHash('sha256').update(password, 'utf-8').digest(); //**
        let combinerBuffer = Buffer.from(data, 'base64'); //**
        let iv = combinerBuffer.slice(0,12); //**
        let deciper = crypto.createDecipheriv('aes-256-gcm', password_hash, iv);
        let temp = combinerBuffer.length-16;
        deciper.setAuthTag(combinerBuffer.slice(temp));
        return deciper.update(combinerBuffer.slice(12,temp), 'utf8') + deciper.final('utf8');
    }

let p = 'password', i = 'This is data';
let c = encryptData(i,p); console.log(c);
let d = decryptData(c,p); console.log(d);

Finally, using a single fast and unsalted hash of a password for a key has very little security and will likely be broken. But that's a design issue, and offtopic for SO. If you have the power to change this design and care about actual security, see security.SX, where you will find many recommendations to at least use something like PBKDF2 (an iterated, salted HMAC) or even better one of the newer, memory-hard password hashes like scrypt or argon2.

Also, as I commented, the IV/nonce for GCM is only required to be unique; using a secure random generator is one common way of obtaining unique values, but not the only one. (This contrasts to CBC mode where the IV must be unique and unpredictable, which in practice requires either random or SIV.)