aboutsummaryrefslogtreecommitdiffstats
path: root/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
blob: 90b8beb461f1d738a41bbf3cc83433a8dc05e34f (plain) (blame)
1
2
3
4
5
6
7
8
9
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.security;

import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;

import static com.yahoo.security.ArrayUtils.hex;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class SharedKeyTest {

    private static final KeyId KEY_ID_1 = KeyId.ofString("1");
    private static final KeyId KEY_ID_2 = KeyId.ofString("2");

    @Test
    void sealed_shared_key_uses_enc_and_ciphertext_contents_for_equals_and_hash_code() {
        var tokenStr1 = "2qW20eDfgCxDVTJfLPzihhqV4i1Ma6QrvjdoU24Csf6W0iKbYmezchhxIGeI39WcHYDvbah5tfLoYZ69ofW40zy59Nm91tavFsA";
        var tokenStr2 = "mjA83HYuulZW5SWV8FKz4m3b3m9zU8mTrX9n6iY4wZaA6ZNr8WnBZwOU4KQqhPCORPlzSYk4svlonzPZIb3Bjbqr2ePYKLOpdGhCO";
        var token1a = SealedSharedKey.fromTokenString(tokenStr1);
        var token1b = SealedSharedKey.fromTokenString(tokenStr1);
        var token2a = SealedSharedKey.fromTokenString(tokenStr2);
        var token2b = SealedSharedKey.fromTokenString(tokenStr2);
        assertEquals(token1a, token1a); // trivial
        assertEquals(token1a, token1b); // needs deep compare for array contents
        assertEquals(token1b, token1a);
        assertEquals(token2a, token2b);
        assertNotEquals(token1a, token2a);

        assertEquals(token1a.hashCode(), token1b.hashCode());
        assertEquals(token2a.hashCode(), token2b.hashCode());
        assertNotEquals(token1a.hashCode(), token2a.hashCode()); // ... with a very high probability
    }

    @Test
    void generated_secret_key_is_128_bit_aes() {
        var receiverKeyPair = KeyUtils.generateX25519KeyPair();
        var shared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), KEY_ID_1);
        var secret = shared.secretKey();
        assertEquals(secret.getAlgorithm(), "AES");
        assertEquals(secret.getEncoded().length, 16);
    }

    @Test
    void sealed_shared_key_can_be_exchanged_via_token_and_computes_identical_secret_key_at_receiver() {
        var receiverKeyPair = KeyUtils.generateX25519KeyPair();

        var myShared    = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), KEY_ID_1);
        var publicToken = myShared.sealedSharedKey().toTokenString();

        var theirSealed = SealedSharedKey.fromTokenString(publicToken);
        var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverKeyPair.getPrivate());

        assertArrayEquals(myShared.secretKey().getEncoded(), theirShared.secretKey().getEncoded());
    }

    @Test
    void secret_key_can_be_resealed_for_another_receiver() {
        var originalReceiverKp  = KeyUtils.generateX25519KeyPair();
        var secondaryReceiverKp = KeyUtils.generateX25519KeyPair();
        var myShared = SharedKeyGenerator.generateForReceiverPublicKey(originalReceiverKp.getPublic(), KEY_ID_1);
        var theirShared = SharedKeyGenerator.reseal(myShared, secondaryReceiverKp.getPublic(), KEY_ID_2);

        var publicToken = theirShared.sealedSharedKey().toTokenString();
        var theirSealed = SealedSharedKey.fromTokenString(publicToken);
        assertEquals(KEY_ID_2, theirSealed.keyId());
        theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, secondaryReceiverKp.getPrivate());
        // Should be same internal secret key
        assertArrayEquals(myShared.secretKey().getEncoded(), theirShared.secretKey().getEncoded());
    }

    @Test
    void resealed_token_preserves_token_version_of_source_token() {
        var originalPrivate = KeyUtils.fromBase58EncodedX25519PrivateKey("GFg54SaGNCmcSGufZCx68SKLGuAFrASoDeMk3t5AjU6L");
        var v1Token         = "OntP9gRVAjXeZIr4zkYqRJFcnA993v7ZEE7VbcNs1NcR3HdE7Mpwlwi3r3anF1kVa5fn7O1CyeHQpBWpdayUTKkrtyFepG6WJrZdE";

        var originalSealed = SealedSharedKey.fromTokenString(v1Token);
        var originalSecret = SharedKeyGenerator.fromSealedKey(originalSealed, originalPrivate);

        var secondaryReceiverKp = KeyUtils.generateX25519KeyPair();
        var resealedShared = SharedKeyGenerator.reseal(originalSecret, secondaryReceiverKp.getPublic(), KEY_ID_2);

        var theirSealed = SealedSharedKey.fromTokenString(resealedShared.sealedSharedKey().toTokenString());
        assertEquals(1, theirSealed.tokenVersion());
    }

    @Test
    void token_v1_representation_is_stable() throws IOException {
        var receiverPrivate = KeyUtils.fromBase58EncodedX25519PrivateKey("GFg54SaGNCmcSGufZCx68SKLGuAFrASoDeMk3t5AjU6L");
        var receiverPublic  = KeyUtils.fromBase58EncodedX25519PublicKey( "5drrkakYLjYSBpr5Haknh13EiCYL36ndMzK4gTJo6pwh");
        var keyId           = KeyId.ofString("my key ID");

        // V1 token generated for the above receiver public key, with the below expected shared secret (in hex)
        var publicToken = "OntP9gRVAjXeZIr4zkYqRJFcnA993v7ZEE7VbcNs1NcR3HdE7Mpwlwi3r3anF1kVa5fn7O1CyeHQpBWpdayUTKkrtyFepG6WJrZdE";
        var expectedSharedSecret = "1b33b4dcd6a94e5a4a1ee6d208197d01";

        var theirSealed = SealedSharedKey.fromTokenString(publicToken);
        var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverPrivate);

        assertEquals(1, theirSealed.tokenVersion());
        assertEquals(keyId, theirSealed.keyId());
        assertEquals(expectedSharedSecret, hex(theirShared.secretKey().getEncoded()));

        // Encryption with v1 tokens must use AES-GCM 128
        var plaintext = "it's Bocchi time";
        var expectedCiphertext = "a2ba842b2e0769a4a2948c4236d4ae921f1dd05c2e094dcde9699eeefcc3d7ae";
        byte[] ct = streamEncryptString(plaintext, theirShared);
        assertEquals(expectedCiphertext, hex(ct));

        // Decryption with v1 tokens must use AES-GCM 128
        var decrypted = streamDecryptString(ct, theirShared);
        assertEquals(plaintext, decrypted);
    }

    @Test
    void token_v2_representation_is_stable() throws IOException {
        var receiverPrivate = KeyUtils.fromBase58EncodedX25519PrivateKey("GFg54SaGNCmcSGufZCx68SKLGuAFrASoDeMk3t5AjU6L");
        var receiverPublic  = KeyUtils.fromBase58EncodedX25519PublicKey( "5drrkakYLjYSBpr5Haknh13EiCYL36ndMzK4gTJo6pwh");
        var keyId           = KeyId.ofString("my key ID");

        // V2 token generated for the above receiver public key, with the below expected shared secret (in hex)
        var publicToken = "mjA83HYuulZW5SWV8FKz4m3b3m9zU8mTrX9n6iY4wZaA6ZNr8WnBZwOU4KQqhPCORPlzSYk4svlonzPZIb3Bjbqr2ePYKLOpdGhCO";
        var expectedSharedSecret = "205af82154690fd7b6d56a977563822c";

        var theirSealed = SealedSharedKey.fromTokenString(publicToken);
        var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverPrivate);

        assertEquals(2, theirSealed.tokenVersion());
        assertEquals(keyId, theirSealed.keyId());
        assertEquals(expectedSharedSecret, hex(theirShared.secretKey().getEncoded()));

        // Encryption with v2 tokens must use ChaCha20-Poly1305
        var plaintext = "it's Bocchi time";
        var expectedCiphertext = "ea19dd0ac3ea6d76dc4e96430b0d5902a21cb3a27fa99490f4dcc391eaf5cec4";
        byte[] ct = streamEncryptString(plaintext, theirShared);
        assertEquals(expectedCiphertext, hex(ct));

        // Decryption with v2 tokens must use ChaCha20-Poly1305
        var decrypted = streamDecryptString(ct, theirShared);
        assertEquals(plaintext, decrypted);
    }

    @Test
    void unrelated_private_key_cannot_decrypt_shared_secret_key() {
        var aliceKeyPair = KeyUtils.generateX25519KeyPair();
        var eveKeyPair   = KeyUtils.generateX25519KeyPair();
        var bobShared    = SharedKeyGenerator.generateForReceiverPublicKey(aliceKeyPair.getPublic(), KEY_ID_1);
        assertThrows(RuntimeException.class, // TODO consider distinct exception class
                     () -> SharedKeyGenerator.fromSealedKey(bobShared.sealedSharedKey(), eveKeyPair.getPrivate()));
    }

    @Test
    void token_carries_opaque_key_id_bytes_as_metadata() {
        var keyId       = KeyId.ofString("hello key id world");
        var keyPair     = KeyUtils.generateX25519KeyPair();
        var myShared    = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), keyId);
        var publicToken = myShared.sealedSharedKey().toTokenString();
        var theirShared = SealedSharedKey.fromTokenString(publicToken);
        assertEquals(theirShared.keyId(), keyId);
    }

    @Test
    void key_id_integrity_is_protected_by_aad() {
        var goodId   = KeyId.ofString("my key 1");
        var keyPair  = KeyUtils.generateX25519KeyPair();
        var myShared = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), goodId);
        var mySealed = myShared.sealedSharedKey();
        var badId    = KeyId.ofString("my key 2");

        var tamperedShared = new SealedSharedKey(SealedSharedKey.CURRENT_TOKEN_VERSION, badId, mySealed.enc(), mySealed.ciphertext());
        // Should not be able to unseal the token since the AAD auth tag won't be correct
        assertThrows(RuntimeException.class, // TODO consider distinct exception class
                     () -> SharedKeyGenerator.fromSealedKey(tamperedShared, keyPair.getPrivate()));
    }

    @Test
    void can_encode_and_decode_largest_possible_key_id() {
        byte[] okIdBytes = new byte[KeyId.MAX_KEY_ID_UTF8_LENGTH];
        Arrays.fill(okIdBytes, (byte)'A');
        var okId     = KeyId.ofBytes(okIdBytes);
        var keyPair  = KeyUtils.generateX25519KeyPair();
        var myShared = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), okId);
        assertEquals(okId, myShared.sealedSharedKey().keyId());

        var asToken = myShared.sealedSharedKey().toTokenString();
        var decoded = SealedSharedKey.fromTokenString(asToken);
        assertEquals(okId, decoded.keyId());
    }

    // TODO make this test less implementation specific if possible...
    @Test
    void malformed_utf8_key_id_is_rejected_on_parsing() {
        var goodId   = KeyId.ofBytes(new byte[] { (byte)'A' });
        var keyPair  = KeyUtils.generateX25519KeyPair();
        var myShared = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), goodId);

        // token header is u8 version || u8 key id length || key id bytes ...
        // Since the key ID is only 1 byte long, patch it with a bad UTF-8 value
        byte[] tokenBytes = Base62.codec().decode(myShared.sealedSharedKey().toTokenString());
        tokenBytes[2] = (byte)0xC0; // First part of a 2-byte continuation without trailing byte
        var patchedTokenStr = Base62.codec().encode(tokenBytes);
        assertThrows(IllegalArgumentException.class, () -> SealedSharedKey.fromTokenString(patchedTokenStr));
    }

    static byte[] streamEncryptString(String data, SecretSharedKey secretSharedKey) throws IOException {
        var cipher = secretSharedKey.makeEncryptionCipher();
        var outStream = new ByteArrayOutputStream();
        try (var cipherStream = cipher.wrapOutputStream(outStream)) {
            cipherStream.write(data.getBytes(StandardCharsets.UTF_8));
            cipherStream.flush();
        }
        return outStream.toByteArray();
    }

    static String streamDecryptString(byte[] encrypted, SecretSharedKey secretSharedKey) throws IOException {
        var cipher   = secretSharedKey.makeDecryptionCipher();
        var inStream = new ByteArrayInputStream(encrypted);
        var total    = ByteBuffer.allocate(encrypted.length); // Assume decrypted form can't be _longer_
        byte[] tmp   = new byte[8]; // short buf to test chunking
        try (var cipherStream = cipher.wrapInputStream(inStream)) {
            while (true) {
                int read = cipherStream.read(tmp);
                if (read == -1) {
                    break;
                }
                total.put(tmp, 0, read);
            }
        }
        total.flip();
        byte[] strBytes = new byte[total.remaining()];
        total.get(strBytes);
        return new String(strBytes, StandardCharsets.UTF_8);
    }

    @Test
    void can_create_symmetric_ciphers_from_shared_secret_key_and_public_keys() throws Exception {
        var receiverKeyPair = KeyUtils.generateX25519KeyPair();
        var myShared        = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), KEY_ID_1);

        String terrifyingSecret = "birds are not real D:";
        byte[] encrypted = streamEncryptString(terrifyingSecret, myShared);
        String decrypted = streamDecryptString(encrypted, myShared);
        assertEquals(terrifyingSecret, decrypted);
    }

    @Test
    void shared_key_can_be_resealed_via_interactive_resealing_session() {
        var originalReceiverKp  = KeyUtils.generateX25519KeyPair();
        var shared = SharedKeyGenerator.generateForReceiverPublicKey(originalReceiverKp.getPublic(), KEY_ID_1);
        var secret = hex(shared.secretKey().getEncoded());

        // Resealing requester side; ask for token to be resealed for ephemeral session public key
        var session = SharedKeyResealingSession.newEphemeralSession();
        var wrappedResealRequest = session.resealingRequestFor(shared.sealedSharedKey());

        // Resealing request handler side; reseal using private key for original token
        var unwrappedResealRequest = SharedKeyResealingSession.ResealingRequest.fromSerializedString(wrappedResealRequest.toSerializedString());
        var wrappedResponse = SharedKeyResealingSession.reseal(unwrappedResealRequest,
                (keyId) -> Optional.ofNullable(keyId.equals(KEY_ID_1) ? originalReceiverKp.getPrivate() : null));

        // Back to resealing requester side
        var unwrappedResponse = SharedKeyResealingSession.ResealingResponse.fromSerializedString(wrappedResponse.toSerializedString());
        var resealed = session.openResealingResponse(unwrappedResponse);

        var resealedSecret = hex(resealed.secretKey().getEncoded());
        assertEquals(secret, resealedSecret);
    }

    // javax.crypto.CipherOutputStream swallows exceptions caused by MAC failures in cipher
    // decryption mode (!) and must therefore _not_ be used for this purpose. This is documented,
    // but still very surprising behavior.
    @Test
    void cipher_output_stream_tag_mismatch_is_not_swallowed() throws Exception {
        var receiverKeyPair = KeyUtils.generateX25519KeyPair();
        var myShared        = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), KEY_ID_1);
        String plaintext = "...hello world?";
        byte[] encrypted = streamEncryptString(plaintext, myShared);
        // Corrupt MAC tag in ciphertext
        encrypted[encrypted.length - 1] ^= (byte)0x80;
        // We don't necessarily know _which_ exception is thrown, but one _should_ be thrown!
        assertThrows(Exception.class, () -> doOutputStreamCipherDecrypt(myShared, encrypted));
        // Also try with corrupted ciphertext (pre MAC tag)
        encrypted[encrypted.length - 1] ^= (byte)0x80; // Flip MAC bit back to correct state
        encrypted[encrypted.length - 17] ^= (byte)0x80; // Pre 128-bit MAC tag
        assertThrows(Exception.class, () -> doOutputStreamCipherDecrypt(myShared, encrypted));
    }

    private static void doOutputStreamCipherDecrypt(SecretSharedKey myShared, byte[] encrypted) throws Exception {
        var cipher = myShared.makeDecryptionCipher();
        var outStream = new ByteArrayOutputStream();
        try (var cipherStream = cipher.wrapOutputStream(outStream)) {
            cipherStream.write(encrypted);
            cipherStream.flush();
        }
    }

}