2
votes

I'm trying to encrypt any java object (in this example an Integer, but Date should also work) to a base64 string using the Cipher class. Basically I convert the given object into a byte array using ByteArrayOutputStream and encrypt this byte array with Cipher. See below

for (Integer i = 0; i < 10; i++) {

    ByteArrayOutputStream bos = new ByteArrayOutputStream();

    ObjectOutput oos = new ObjectOutputStream(bos);
    oos.writeObject(i);
    oos.flush();

    byte[] data = bos.toByteArray();


    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec("&E(H+MbQeThWmZq4".getBytes("UTF-8"), "AES"));

    String base64output = Base64.getEncoder().encodeToString(cipher.doFinal(data));

    System.out.println(i + " - " + base64output);
}

The output

0 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa94LOaOdEXeZZm8qNoELOLdj
1 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97aK6ELffW8n7vEkNAbC9RW
2 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97mJ1m8lVtjwfGbHbMO2rxu
3 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa942rroZJbe2KN0/t8ukOkWd
4 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97rbkvF4HLzuvGTm4JMJw+2
5 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa94zvSlIQe8RQI8t5/H74ShO
6 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97tNLWZHmR0rNkDXZtVWA2Y
7 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa94lr84KZ6MnUsPOFyJIfDTB
8 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97e6ihJ8SXmz9sy9XXwWeAz
9 - BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa97neBL2tLG2TXgCI/wDuyMo

seems strange to me because of the same prefix BroJyDQdUDVYwq6tUdk9UcgIX8R7+B474UFw/HFx9lGpDjC0ilKxw8fYd1hFB54f8shrn/XIT52WzcOsH0CBGJ3bva8Rk1h4uYo5sfpJa9 for every encrypted object. In this example, I'm using the same key for every object but this should not be the reason for this issue.

I've also tested this example with Strings and Dates instead of Integers. Encoding Dates into byte arrays and encrypting them with the same method also lead into this issue having an identical prefix for all Date objects, while encoding Strings with the same method seem to work fine. Every encoded and encrypted String leads to another encrypted base64 String. See below

Outputs for encrypted Dates: (also with identical prefix)

0 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JRj1HrbSaioOqhbM2uZi2r0
1 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JQ0q0kophfAfiPxe0U+sb1R
2 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JTeTKnbYsLo6TjfuQF9PYIk
3 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JSrDPGtepg4HWUL6VeBtWg7
4 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JS7dlSsNjnY011F2BooNnKW
5 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JStO2xPQvT76/k+xMdaDBpQ
6 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JQqz4J3yO8G9taHi7b/Zefl
7 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JR8/fOAiuGM8tO8zMcju4Xk
8 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JSMDHi6UyD5QQY1jRXNCErc
9 - cpQxMKQW7mHCKsxxsyMMJTRPnfgujbJYLiVKeHgM2JRfKstfsC8dPYuPfd9f2B+B

Outputs for encrypted Strings: (works as expected)

0 - TNpI3oLRzH5id6c/yRJlQQ==
1 - yMkm+ZuYWs4EnISo56Zljw==
2 - 03i1Lv01Nn2sGDGmtpRAIg==
3 - 5skvWbkcVXfT2TScaGxNfQ==
4 - 0p9qg5U+DqAnCBdyji+L9Q==
5 - gD5xPtAMy34xC90hKCQeWA==
6 - oQwKUhuxC5X/f6U9G9la8Q==
7 - 72cvCiLks3DDaTLAQvoVfw==
8 - wQu7Ug5RHg5egbNTI0YXQw==
9 - x1BQVwy3r6MP3SDLl/mktw==

Any idea?

Edit: Even when I use CBC or another encryption method like DES or Blowfish the same issue occurs. I expect that every byte array from ByteArrayOutputStream should be encrypted into a completely different base64 string even if they have an identical prefix with ~90% of their length.

3
The serialized integers seem awfully long. Try to inspect the byte arrays (data) of the serialized integers. I've never done that but I suspect they have a header or something similar. - Mark Jeronimus
I guess that's a header information for the datatype itself created by ByteArrayOutputStream. In my last edit, I've already mentioned that this shouldn't be a reason (in my opinion). - ke_let
@ke_let cryptography isn't about opinions. You're not using a salt, so you get the common prefix. Throw in a salt and they're gone. - Kayaman
@ke_let when trying out CBC, you most likely omitted the initialization vectors (IVs), see my answer below. - David Soroko

3 Answers

3
votes

Using object serialization before encryption is not a great idea. Either you encrypt the data for transport protection, in which case TLS makes much more sense. Or you are encrypting for longer time storage, in which case serialization is dangerous as the serialization format could change. Heck, you might want to change the entire language / runtime in the future.

I'd suggest you generate your own protocol. In that case you can for instance simply encode an integer to 4 bytes using ByteBuffer#putInt(int) or by using DataOutputStream#writeInt(int). That way your integer just takes the minimum amount of 4 bytes (as an unsigned 32 bit big endian value). For very complex methods you may even look at ASN.1 structures and encodings (which are implemented in Bouncy Castle, among other libraries).

A Java Date is really just a long internally, which can be perfectly stored in 8 bytes. Another option is to encode it to an (UTC) date string and store that using US ASCII compatible encoding (StandardCharsets.US_ASCII).


Beware that ECB mode is very dangerous. For instance, imagine that values above 0x00FFFFFF are uncommon and that you don't want to leak the presence of such values. Also imagine that the most significant byte is the last byte of a block that is filled with header bytes otherwise. In that case it is very easy to distinguish blocks with e.g. 0x01 from blocks with 0x00 which should be more common in this situation. So you immediately leak information about your plaintext.

This problem is just as prominent in CBC mode if you use a static IV rather than a random (or at least fully unpredictable) IV value. You have to use a random IV for each CBC encryption to be secure. You can store the IV together with the ciphertext. Commonly for CBC the 16 byte IV is simply prefixed to the ciphertext. However, preferably you should be using the authenticated GCM mode with a 12 byte random nonce instead.

It's a bit of a shame that Java allows reuse of the Cipher instances at all - as it for instance doesn't allow the Cipher to destroy key material after usage. That it defaults to an insecure mode where the IV is repeated is doubly shameful. You'll have to take care of the IV issue yourself.


Example of using GCM and ByteBuffer:

public static void main(String[] args) throws Exception {

    // input, a date and message
    Date date = new Date();
    String message = "hello world";

    // AES-128 key (replace by a real 256 bit key in your case)
    SecretKey aesKey = new SecretKeySpec(new byte[128 / Byte.SIZE], "AES");

    // default nonce sizes for GCM, using a constant should be preferred
    int nonceSize = 96;
    int tagSize = 128;

    String cts;
    try (StringWriter stringWriter = new StringWriter(); PrintWriter out = new PrintWriter(stringWriter)) {

        for (Integer i = 0; i < 10; i++) {
            byte[] randomNonce = createRandomIV(nonceSize);
            GCMParameterSpec gcmSpec = new GCMParameterSpec(128, randomNonce);

            byte[] encodedMessage = message.getBytes(StandardCharsets.UTF_8);

            ByteBuffer encodedNumberDateAndMessage = ByteBuffer.allocate(Integer.BYTES + Long.BYTES + encodedMessage.length);

            encodedNumberDateAndMessage.putInt(i);
            encodedNumberDateAndMessage.putLong(date.getTime());
            encodedNumberDateAndMessage.put(encodedMessage);
            // for reading we need to flip the buffer
            encodedNumberDateAndMessage.flip();

            ByteBuffer encryptedNumberDateAndMessage =
                    ByteBuffer.allocate(nonceSize / Byte.SIZE + encodedNumberDateAndMessage.limit() + tagSize / Byte.SIZE);

            encryptedNumberDateAndMessage.put(randomNonce);

            Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
            gcm.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);

            gcm.doFinal(encodedNumberDateAndMessage, encryptedNumberDateAndMessage);
            // not required, we'll be using array() method
            // encryptedNumberDateAndMessage.flip();

            // we can use the full array as there 
            String base64Ciphertext = Base64.getEncoder().encodeToString(encryptedNumberDateAndMessage.array());
            if (i != 0) {
                out.write('\n');
            }
            out.write(base64Ciphertext);
        }
        cts = stringWriter.toString();
    }
    System.out.println(cts);

    // TODO decrypt ciphertexts in cts
    // hint use BufferedReader to read lines and don't forget to strip off the IV/Nonce first
}

private static byte[] createRandomIV(int sizeInBits) {
    if (sizeInBits % Byte.SIZE != 0) {
        throw new IllegalArgumentException("Invalid IV size, must be a multiple of 8 bits");
    }
    byte[] randomNonce = new byte[sizeInBits / Byte.SIZE];
    SecureRandom rbg = new SecureRandom();
    rbg.nextBytes(randomNonce);
    return randomNonce;
}

Which produces the seemingly random output:

LHMsZPgZOz7nEcN5adB03+twTG2/ITfPnUUy4DxdgFEBAxm3HNDg8eXVnuvo80i4WMjY
eRJuw1ynrD3GeMmFTYiQc6VxelJuz8wHZtbl+7cepteKdtzcsdIDcDHBqvfjyzZp6WXd
MOkTLt4pk+sFm6I+CH4c90lxrRmwFKmS1wbX5eRSZYy6xqEjSz6iGC1vBXkPbl3k1C5r
cB5hKbpiAeNmbZYy1vdK5vissWYlkL6h6XJEYEFZaK7M097LkVAB01nu5GtCBUjPMjrK
LHzr/iudU3BPYmrimAIugjSckzXrzm03Ucgyb8laKktbh/Um4K2nyAGE2+T1aLH6JaYX
dg9SmcPl+dolHSIQPyvMUEPyu3VLSNPbN7ErPY93sjfKVyZsaGgft/cP4kUzNWEyRgAo
PiLHu4TKZMfBlFXst1867hEywST3RBbSSQ1g9D4DOkqh3oPkvsXP5INIEANZr2BHta38
4pJITAvij26NphYf9/ry5yGm+qPAaNG0Hqrk5ruVa60+V7k0jqDozjsST8OygyvkLrgY
HI6I3UHgzBNjskSJeo9fS3Cw3oKY8tneFbChtLz35DbcASOjpi7U9LKTL39lBTOBaZkG
jRycn4uSfT6JlDk3jn64wTL07I7bHvTSPSbWVG7XdKeSgOibW7FiCtTXojDPi8iywD58

which consists of the nonce, the ciphertext of an integer, a long value representing the date and the "hello world" string and finally the authentication tag, which is considered part of the ciphertext in Java.

1
votes

The behavior you see is the result of you using the no-mode ECB mode in combination with similarity in the plaintext. You will have the same issue with all block ciphers (AES, Blowfish, DES).

When using CBC all that goes away if you provide IVs as needed:

public class Main {
    static Random rand = new SecureRandom();

public static IvParameterSpec generateIv() {
    byte[] ivBytes = new byte[16];
    rand.nextBytes(ivBytes);
    return new IvParameterSpec(ivBytes);
}

public static void main(String[] args) throws Exception {

    for (Integer i = 0; i < 10; i++) {

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutput oos = new ObjectOutputStream(bos);
        oos.writeObject(i);
        oos.flush();

        byte[] data = bos.toByteArray();

        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        IvParameterSpec iv = generateIv();
        cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec("&E(H+MbQeThWmZq4".getBytes("UTF-8"), "AES"), iv);

        String base64output = Base64.getEncoder().encodeToString(cipher.doFinal(data));
        System.out.println(i + " - " + base64output);
    }
}
}

Output:

0 - jt3Mk13pGjeaFf1oNq4LfmQ4z/31nRG4KtZ4H3RK6k/GA1anC3/lrzSXoLsQ6jMsVEpnxU13wAu6lkZJ3it1Ei4i4EsNFixc+YX4K6cIIv4ByY5Q246jd3H0m11C2FZJ
1 - Jqd0RB6lOITqifAaWluW6jx8F8gY4btZHx12CiXtZjfnehhtk64jva4eGTQd4EpvB/5Q/ORhZCNgF3ue0/Na1R9MCsK+mULAcyANdNcLyKbXo272G21z0LPCeweXdjhu
2 - xHdCG6rWNDyLTl8zruo8u+45V/RMXkrB7K+QU5r9lpc3FzvDwpl0wmy9Yj3FOyjMulmVT1zahH+wWVrmB9gNcXy7sGyCH/anJANC396OcDyQXqNIyvOPw9mpUmmRQcwR
3 - ygIDkLtQTupkbB35SzRflE3RAMmdYGSkdGZgRctFHdZCqGt+Arb3RbvhoAiiE9PwkyLmifyllQTTSutvV/ZtlGaGMX3v4bQUZDoaSyXQd9xn+pUSJk87NDVGi37xWw1O
4 - cJYSthCHHGeCqnuBJY8YdUbptKD3XNb2nt+pyIc94vRvjquYf7atu0+bDndFnePWvrlPzFIFXVB8CuANIsDhzRSNEOOU/wOkwcAN2AdavCqlZqN0Mtqdg4vqKGWx2oAE
5 - f7/gu8fJ8jkyhRAXJkLqdnJMLjCfFSjq8ovjhlNcuDPk8N/mYlA2845PGgi74Kb/zCG1WH8NtFK06xrpn15KyUxSANxoQ6C9QnzE9sc4aZj5rUatWeekvBfbqngq3JpG
6 - PitP2MuX4/Yysso8dCl1h2VK3MKoU2YpyzvLgZ3hZX/cBzSWp9O0Eafzj6GIMvAGVaL0x0V+K2Wv4eBOLIhDczhJXvHmKvTU7ZJnAwI37JXkOecN4HJdAXfFqg2WkT5f
7 - 1Mj8WnSqgLE08qfeYC1a3nZQ1jszxbT9J+ClUy8rCYusZHiArQcCgCwrNbWbI2yVfRjYOpsuTgyq31fnuHrkVfGu6RhiRhucR0a0Dign5fSU71STKksweHQ+oYQJibnQ
8 - TgGDGlOFWyfKO50xxPTPOmSpEsmpIVtWfnXkhhAoRsbZwo6z4oAuBJQs8EibsOr/r8KY5UHRbp+q3SlDhBE3mWszybMdOVRQKyJ1lZVXpmxmjXp/W2AqitsjCTKQaHi+
9 - 4xUnNjT8P0WiPtYg6ojrrQZnF0gU0wnndNQdLfPOMxoDvWjfe5OuEcY55yDRIosdpkeItTMVN1CRL4WecFgM8mBIVlnssE4Q1GM87PWNHipGZ91+MJwdsr0yUfCsJyRv

By the way you are using a 16 byte key and get AES-128 not AES-256.

0
votes

As Mark pointed out, ObjectOutputStream creates an object header, so the common prefix is because of that and because you're not using a salt and you're using the same encryption key.

These weaknesses make the encryption solution (i.e. your code) susceptible to ciphertext-only attacks, even though the algorithm itself is perfectly fine. You've just implemented it in an unsecure way.