Статьи

Автоматическое шифрование Сериализуемых Классов

Во время посмертных дискуссий в рамках проекта по созданию замков в области безопасности Coursera возникла безумная идея. Может ли класс зашифровать себя во время сериализации?

Это в основном академическое упражнение «что если». Трудно представить себе ситуацию, когда мы хотели бы полагаться на самошифрование объекта вместо использования явного механизма шифрования во время сохранения. Мне удалось определить только одну ситуацию, когда мы не можем просто сделать класс невозможным для сериализации:

HTTPSession пассивация

Серверы приложений могут пассивировать неактивные сеансы HTTPS для экономии места или для переноса сеанса с одного сервера на другой. Вот почему сеансы должны содержать только сериализуемые объекты. (Это ограничение часто игнорируется в небольших приложениях, которые могут уместиться на одном сервере, но это может вызвать проблемы, если необходимо увеличить или уменьшить реализацию).

Один из подходов (и предпочтительный подход?) Состоит в том, чтобы сеанс записывал себя в базу данных во время пассивации и перезагружал себя во время активации. Единственная фактически сохраненная информация — это то, что требуется для перезагрузки данных, обычно это просто идентификатор пользователя. Это добавляет немного сложности реализации HTTPSession, но имеет много преимуществ. Одним из основных преимуществ является то, что тривиально гарантировать, что конфиденциальная информация шифруется.

Это не единственный подход, и некоторые сайты предпочитают использовать стандартную сериализацию. Некоторые серверы приложений могут хранить копии сериализованных копий «живых» сеансов во встроенной базе данных, такой как H2. Осторожный разработчик может захотеть обеспечить шифрование конфиденциальной информации во время сериализации, даже если это никогда не произойдет.

Примечание: можно привести веский аргумент, что конфиденциальная информация не должна быть в сеансе в первую очередь — извлекать ее только при необходимости и безопасно отбрасывать, когда она больше не нужна.

Подход

Подход, который я использую, основан на главе сериализации в Effective Java . В общих чертах мы хотим использовать прокси сериализации для обработки фактического шифрования. Поведение это:

действие метод Защищенный сериализованный класс Сериализация прокси
Сериализация writeReplace () создать прокси N / A
writeObject () бросить исключение записывать зашифрованное содержимое в ObjectOutputStream
Десериализация readObject () читать зашифрованное содержимое из ObjectInputStream
readResolve () построить объект защищенного класса

Причина, по которой защищенный класс вызывает исключение при вызове методов десериализации, заключается в том, что он предотвращает атаки через сгенерированные злоумышленником сериализованные объекты. См. Обсуждение фиктивной атаки байтовым потоком и внутренней кражи поля в упомянутой выше книге.

Этот подход имеет большое ограничение — класс не может быть расширен без переопределения подкласса прокси. Я не думаю, что это проблема на практике, так как этот метод будет использоваться только для защиты классов, содержащих конфиденциальную информацию, и редко было бы желательно добавлять методы помимо тех, которые ожидали разработчики.

Прокси-класс обрабатывает шифрование. Реализация ниже показывает использование случайного солевого (IV) и криптографически стойкого дайджеста сообщений (HMAC) для обнаружения взлома.

Код

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
     */
    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);
        }
    }
}

Само собой разумеется, что ключи шифрования не должны быть жестко закодированы или, возможно, даже кэшированы, как показано. Это был короткий путь, который позволил нам сосредоточиться на деталях реализации.

Для шифрования и дайджеста сообщений должны использоваться разные ключи. Если вы используете тот же ключ, вы серьезно рискуете безопасностью своей системы.

В любой производственной системе должны выполняться две другие вещи: ротация ключей и смена алгоритмов шифрования и дайджеста. Первый может быть обработан путем добавления «идентификатора ключа» к полезной нагрузке, последний может быть обработан путем связывания номера версии сериализации и алгоритмов шифрования. Например, версия 1 использует стандарт AES, версия 2 использует AES-256. Десериализатор должен иметь возможность обрабатывать старые ключи шифрования и шифры (в пределах разумного).

Тестовый код

Тестовый код прост. Он создает объект, сериализует его, десериализует его и сравнивает результаты с исходным значением.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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());
    }
}

Заключительные слова

Я не могу переоценить это — это прежде всего интеллектуальное упражнение. Как обычно, самой большой проблемой является управление ключами, а не криптография, и с уровнем усилий, необходимым для первого, вы, вероятно, сможете быстрее реализовать более традиционное решение.

Это может все еще быть «достаточно хорошим» в некоторых ситуациях. Например, вам может понадобиться хранить данные только во время работы долго работающего приложения. В этом случае вы можете создавать случайные ключи при запуске и просто отбрасывать все сериализованные данные после завершения программы.