Auto-encrypting Serializable Classes
A crazy idea came up during the post-mortem discussions in the Coursera security capstone project. Can a class encrypt itself during serialization?
This is mostly an academic “what if” exercise. It is hard to think of a situation where we would want to rely on an object self-encrypting instead of using an explicit encryption mechanism during persistence. I’ve only been able to identify one situation where we can’t simply make a class impossible to serialize:
HTTPSession passivation
Appservers may passivate inactive HTTPSessions to save space or to migrate a session from one server to another. This is why sessions should only contain Serializable objects. (This restriction is often ignored in small-scale applications that can fit on a single server but that can cause problems if the implementation needs to be scaled up or out.)
One approach (and the preferred approach?) is for the session to write itself to a database during passivation and reload itself during activation. The only information actually retained is what’s require to reload the data, typically just the user id. This adds a bit of complexity to the HTTPSession implementation but it has many benefits. One major benefit is that it trivial to ensure sensitive information is encrypted.
It’s not the only approach and some sites may prefer to use standard serialization. Some appservers may keep copies of serialized copies of “live” sessions in an embedded database like H2. A cautious developer may want to ensure that sensitive information is encrypted during serialization even if it should never happen.
Note: a strong argument can be made that the sensitive information shouldn’t be in the session in the first place – only retrieve it when necessary and safely discard it once it is no longer needed.
The approach
The approach I’m taking is based on the serialization chapter in Effective Java. In broad terms we want to use a serialization proxy to handle the actual encryption. The behavior is:
Action | Method | Protected Serialized Class | Serialization Proxy |
---|---|---|---|
Serialization | writeReplace() | create proxy | N/A |
writeObject() | throw exception | write encrypted contents to ObjectOutputStream | |
Deserialization | readObject() | read encrypted contents from ObjectInputStream | |
readResolve() | construct protected class object |
The reason the protected class throws an exception when the deserialization methods are called is because it prevents attacks through attacker-generated serialized objects. See the discussion on the bogus byte-stream attack and internal field theft attack in the book mentioned above.
This approach has a big limitation – the class cannot be extended without the subclass reimplementing the proxy. I don’t think this is an issue in practice since this technique will only be used to protect classes containing sensitive information and it would rarely be desirable to add methods beyond the ones anticipated by the designers.
The proxy class handles encryption. The implementation below shows the use of a random salt (IV) and cryptographically strong message digest (HMAC) to detect tampering.
The code
public class ProtectedSecret implements Serializable { private static final long serialVersionUID = 1L; private final String secret; /** * Constructor. * * @param secret */ public ProtectedSecret(final String secret) { this.secret = secret; } /** * Accessor */ public String getSecret() { return secret; } /** * Replace the object being serialized with a proxy. * * @return */ private Object writeReplace() { return new SimpleProtectedSecretProxy(this); } /** * Serialize object. We throw an exception since this method should never be * called - the standard serialization engine will serialize the proxy * returned by writeReplace(). Anyone calling this method directly is * probably up to no good. * * @param stream * @return * @throws InvalidObjectException */ private void writeObject(ObjectOutputStream stream) throws InvalidObjectException { throw new InvalidObjectException("Proxy required"); } /** * Deserialize object. We throw an exception since this method should never * be called - the standard serialization engine will create serialized * proxies instead. Anyone calling this method directly is probably up to no * good and using a manually constructed serialized object. * * @param stream * @return * @throws InvalidObjectException */ private void readObject(ObjectInputStream stream) throws InvalidObjectException { throw new InvalidObjectException("Proxy required"); } /** * Serializable proxy for our protected class. The encryption code is based * on https://gist.github.com/mping/3899247. */ private static class SimpleProtectedSecretProxy implements Serializable { private static final long serialVersionUID = 1L; private String secret; private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; private static final String HMAC_ALGORITHM = "HmacSHA256"; private static transient SecretKeySpec cipherKey; private static transient SecretKeySpec hmacKey; static { // these keys can be read from the environment, the filesystem, etc. final byte[] aes_key = "d2cb415e067c7b13".getBytes(); final byte[] hmac_key = "d6cfaad283353507".getBytes(); try { cipherKey = new SecretKeySpec(aes_key, "AES"); hmacKey = new SecretKeySpec(hmac_key, HMAC_ALGORITHM); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } /** * Constructor. * * @param protectedSecret */ SimpleProtectedSecretProxy(ProtectedSecret protectedSecret) { this.secret = protectedSecret.secret; } /** * Write encrypted object to serialization stream. * * @param s * @throws IOException */ private void writeObject(ObjectOutputStream s) throws IOException { s.defaultWriteObject(); try { Cipher encrypt = Cipher.getInstance(CIPHER_ALGORITHM); encrypt.init(Cipher.ENCRYPT_MODE, cipherKey); byte[] ciphertext = encrypt.doFinal(secret.getBytes("UTF-8")); byte[] iv = encrypt.getIV(); Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(hmacKey); mac.update(iv); byte[] hmac = mac.doFinal(ciphertext); // TBD: write algorithm id... s.writeInt(iv.length); s.write(iv); s.writeInt(ciphertext.length); s.write(ciphertext); s.writeInt(hmac.length); s.write(hmac); } catch (Exception e) { throw new InvalidObjectException("unable to encrypt value"); } } /** * Read encrypted object from serialization stream. * * @param s * @throws InvalidObjectException */ private void readObject(ObjectInputStream s) throws ClassNotFoundException, IOException, InvalidObjectException { s.defaultReadObject(); try { // TBD: read algorithm id... byte[] iv = new byte[s.readInt()]; s.read(iv); byte[] ciphertext = new byte[s.readInt()]; s.read(ciphertext); byte[] hmac = new byte[s.readInt()]; s.read(hmac); // verify HMAC Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(hmacKey); mac.update(iv); byte[] signature = mac.doFinal(ciphertext); // verify HMAC if (!Arrays.equals(hmac, signature)) { throw new InvalidObjectException("unable to decrypt value"); } // decrypt data Cipher decrypt = Cipher.getInstance(CIPHER_ALGORITHM); decrypt.init(Cipher.DECRYPT_MODE, cipherKey, new IvParameterSpec(iv)); byte[] data = decrypt.doFinal(ciphertext); secret = new String(data, "UTF-8"); } catch (Exception e) { throw new InvalidObjectException("unable to decrypt value"); } } /** * Return protected object. * * @return */ private Object readResolve() { return new ProtectedSecret(secret); } } }
It should go without saying that the encryption keys should not be hard-coded or possibly even cached as shown. This was a short-cut to allow us to focus on the details of the implementation.
Different keys should be used for the cipher and message digest. You will seriously compromise the security of your system if the same key is used.
Two other things should be handled in any production system: key rotation and changing the cipher and digest algorithms. The former can be handled by adding a ‘key id’ to the payload, the latter can be handled by tying the serialization version number and cipher algorithms. E.g., version 1 uses standard AES, version 2 uses AES-256. The deserializer should be able to handle old encryption keys and ciphers (within reason).
Test code
The test code is straightforward. It creates an object, serializes it, deserializes it, and compares the results to the original value.
public class ProtectedSecretTest { /** * Test 'happy path'. */ @Test public void testCipher() throws IOException, ClassNotFoundException { ProtectedSecret secret1 = new ProtectedSecret("password"); ProtectedSecret secret2; byte[] ser; // serialize object try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutput output = new ObjectOutputStream(baos)) { output.writeObject(secret1); output.flush(); ser = baos.toByteArray(); } // deserialize object. try (ByteArrayInputStream bais = new ByteArrayInputStream(ser); ObjectInput input = new ObjectInputStream(bais)) { secret2 = (ProtectedSecret) input.readObject(); } // compare values. assertEquals(secret1.getSecret(), secret2.getSecret()); } /** * Test deserialization after a single bit is flipped. */ @Test(expected = InvalidObjectException.class) public void testCipherAltered() throws IOException, ClassNotFoundException { ProtectedSecret secret1 = new ProtectedSecret("password"); ProtectedSecret secret2; byte[] ser; // serialize object try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutput output = new ObjectOutputStream(baos)) { output.writeObject(secret1); output.flush(); ser = baos.toByteArray(); } // corrupt ciphertext ser[ser.length - 16 - 1 - 3] ^= 1; // deserialize object. try (ByteArrayInputStream bais = new ByteArrayInputStream(ser); ObjectInput input = new ObjectInputStream(bais)) { secret2 = (ProtectedSecret) input.readObject(); } // compare values. assertEquals(secret1.getSecret(), secret2.getSecret()); } }
Final words
I cannot overemphasize this – this is primarily an intellectual exercise. As usual the biggest problem is key management, not cryptography, and with the level of effort required for the former you can probably implement a more traditional solution more quickly.
This may still be “good enough” in some situations. For instance you may only need to keep the data around during the duration of a long-running application. In this case you can create random keys at startup and simply discard all serialized data after the program ends.
Reference: | Auto-encrypting Serializable Classes from our JCG partner Bear Giles at the Invariant Properties blog. |