Skip to content

Commit b45b032

Browse files
committed
Significantly improve Encryptor
- Now allows encrypting/decrypting JsonObjects for more (structured) data - Uses AES encryption to *actually* encrypt the data
1 parent 87c57f5 commit b45b032

File tree

2 files changed

+117
-69
lines changed

2 files changed

+117
-69
lines changed
Lines changed: 117 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,59 @@
11
package xyz.srnyx.javautilities.objects.encryptor;
22

3+
import com.google.gson.JsonElement;
4+
import com.google.gson.JsonObject;
5+
import com.google.gson.JsonPrimitive;
6+
37
import org.jetbrains.annotations.NotNull;
48
import org.jetbrains.annotations.Nullable;
59

610
import xyz.srnyx.javautilities.manipulation.Mapper;
711
import xyz.srnyx.javautilities.objects.encryptor.exceptions.TokenExpiredException;
812
import xyz.srnyx.javautilities.objects.encryptor.exceptions.TokenInvalidException;
9-
import xyz.srnyx.javautilities.objects.encryptor.exceptions.TokenTamperedException;
1013

11-
import javax.crypto.Mac;
14+
import javax.crypto.Cipher;
15+
import javax.crypto.NoSuchPaddingException;
16+
import javax.crypto.spec.GCMParameterSpec;
1217
import javax.crypto.spec.SecretKeySpec;
18+
import java.nio.ByteBuffer;
1319
import java.nio.charset.StandardCharsets;
20+
import java.security.InvalidAlgorithmParameterException;
1421
import java.security.InvalidKeyException;
1522
import java.security.NoSuchAlgorithmException;
23+
import java.security.SecureRandom;
1624
import java.time.Duration;
17-
import java.util.Arrays;
1825
import java.util.Base64;
19-
import java.util.Optional;
2026

2127

2228
/**
2329
* Utility class for encrypting and decrypting values using HMAC signatures
2430
*/
2531
public class Encryptor {
2632
/**
27-
* The algorithm used for signing
33+
* The algorithm used for encryption
34+
* <br>AES (Advanced Encryption Standard) is a symmetric encryption algorithm widely used across the globe
35+
* <br>It is known for its speed and security, making it suitable for encrypting sensitive data
36+
*/
37+
@NotNull private static final String ALGORITHM = "AES";
38+
/**
39+
* The transformation string for AES in GCM mode with NoPadding
40+
* <br>GCM (Galois/Counter Mode) is a mode of operation for symmetric key cryptographic block ciphers
41+
* <br>It provides both confidentiality and data integrity
2842
*/
29-
@NotNull private final String algorithm;
43+
@NotNull private static final String TRANSFORMATION = "AES/GCM/NoPadding";
44+
/**
45+
* The size of the initialization vector (IV) in bits
46+
* <br>GCM typically uses a 96-bit IV, which is recommended for security
47+
* <br>Using a unique IV for each encryption operation is crucial to prevent replay attacks
48+
*/
49+
private static final int IV_SIZE = 12; // 96 bits for GCM
50+
/**
51+
* The size of the authentication tag in bits
52+
* <br>GCM uses a 128-bit tag for authentication, which is standard and provides a good balance between security and performance
53+
* <br>The tag is used to verify the integrity of the encrypted data
54+
*/
55+
private static final int TAG_SIZE = 128;
56+
3057
/**
3158
* The secret key used for signing
3259
*/
@@ -40,94 +67,123 @@ public class Encryptor {
4067
/**
4168
* Creates a new {@link Encryptor}
4269
*
43-
* @param algorithm {@link #algorithm}
4470
* @param secret {@link #secret}
4571
* @param maxAge {@link #maxAge}
4672
*
4773
* @throws NoSuchAlgorithmException if the specified algorithm is not available
4874
* @throws InvalidKeyException if the provided secret is invalid
75+
* @throws NoSuchPaddingException if the specified padding scheme is not available
4976
*/
50-
public Encryptor(@NotNull String algorithm, @NotNull byte[] secret, @Nullable Duration maxAge) throws NoSuchAlgorithmException, InvalidKeyException {
51-
this.algorithm = algorithm;
52-
this.secret = new SecretKeySpec(secret, algorithm);
77+
public Encryptor(@NotNull byte[] secret, @Nullable Duration maxAge) throws NoSuchAlgorithmException, InvalidKeyException, NoSuchPaddingException, InvalidAlgorithmParameterException {
78+
this.secret = new SecretKeySpec(secret, ALGORITHM);
5379
this.maxAge = maxAge;
5480

55-
// Test Mac instance for validity
56-
final Mac mac = Mac.getInstance(algorithm);
57-
mac.init(this.secret);
81+
// Validate
82+
if (maxAge != null && maxAge.isNegative()) throw new IllegalArgumentException("maxAge cannot be negative");
83+
Cipher.getInstance(TRANSFORMATION).init(Cipher.ENCRYPT_MODE, this.secret, new GCMParameterSpec(TAG_SIZE, new byte[IV_SIZE]));
5884
}
5985

6086
/**
61-
* Generates a signature for the given payload
87+
* Generates the cipher text for encryption or decryption
6288
*
63-
* @param payload the payload to sign
89+
* @param mode the mode of operation ({@link Cipher#ENCRYPT_MODE} or {@link Cipher#DECRYPT_MODE})
90+
* @param iv the initialization vector (IV) used for GCM mode
91+
* @param token the token to encrypt or decrypt
6492
*
65-
* @return the signature, or {@code null} if an error occurred
93+
* @return the resulting cipher text as a byte array, or null if an error occurs
6694
*/
6795
@Nullable
68-
private byte[] getSignature(@NotNull String payload) {
96+
private byte[] getCipherText(int mode, byte[] iv, byte[] token) {
6997
try {
70-
final Mac mac = Mac.getInstance(algorithm);
71-
mac.init(secret);
72-
return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
98+
final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
99+
final GCMParameterSpec spec = new GCMParameterSpec(TAG_SIZE, iv);
100+
cipher.init(mode, secret, spec);
101+
return cipher.doFinal(token);
73102
} catch (final Exception e) {
74-
// This should never happen since we test the Mac instance in the constructor
75103
e.printStackTrace();
76104
return null;
77105
}
78106
}
79107

80108
/**
81-
* Encrypts a value by creating a signed token that includes the value and a timestamp
82-
* <br>The token format is {@code base64(value:timestamp:signature)}
109+
* Encrypts a {@link JsonElement} value and returns a Base64 URL-safe string without padding
83110
*
84-
* @param value the value to encrypt, will be converted to string using {@link Object#toString()}
111+
* @param value the {@link JsonElement} value to encrypt
85112
*
86-
* @return the encrypted token, or {@code null} if an error occurred during signature generation
113+
* @return the encrypted token as a Base64 URL-safe string without padding, or null if encryption fails
87114
*/
88115
@Nullable
89-
public String encrypt(@NotNull Object value) {
90-
final String payload = value + ":" + System.currentTimeMillis();
91-
final byte[] signature = getSignature(payload);
92-
if (signature == null) return null; // Should never happen
93-
final String token = payload + ":" + Base64.getUrlEncoder().withoutPadding().encodeToString(signature);
94-
return Base64.getUrlEncoder().withoutPadding().encodeToString(token.getBytes(StandardCharsets.UTF_8));
116+
public String encrypt(@NotNull JsonElement value) {
117+
// Validate value
118+
if (value.isJsonNull()) return null;
119+
120+
// Generate random IV
121+
final byte[] iv = new byte[IV_SIZE];
122+
new SecureRandom().nextBytes(iv);
123+
124+
// Create payload with timestamp and value
125+
final JsonObject payload = new JsonObject();
126+
payload.addProperty("timestamp", String.valueOf(System.currentTimeMillis())); // Store as string to prevent rounding
127+
payload.add("value", value);
128+
129+
// Encrypt payload
130+
final byte[] ciphertext = getCipherText(Cipher.ENCRYPT_MODE, iv, payload.toString().getBytes(StandardCharsets.UTF_8));
131+
if (ciphertext == null) return null;
132+
133+
// Combine IV and ciphertext
134+
final ByteBuffer buffer = ByteBuffer.allocate(IV_SIZE + ciphertext.length);
135+
buffer.put(iv);
136+
buffer.put(ciphertext);
137+
138+
// Encode to Base64 URL-safe string without padding
139+
return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.array());
95140
}
96141

97142
/**
98-
* Decrypts a token by verifying its signature and timestamp
99-
*
100-
* @param token the token to decrypt
143+
* Decrypts a Base64 URL-safe string token and returns the original {@link JsonElement} value
101144
*
102-
* @return the original value if the token is valid and not expired, otherwise {@code null}
145+
* @param token the Base64 URL-safe string token to decrypt
103146
*
104-
* @throws TokenInvalidException if the token is invalid
105-
* @throws TokenExpiredException if the token has expired
106-
* @throws TokenTamperedException if the token has been tampered with
147+
* @return the decrypted {@link JsonElement} value, or null if decryption fails or token is invalid
107148
*/
108149
@Nullable
109-
public String decrypt(@NotNull String token) throws TokenInvalidException, TokenExpiredException, TokenTamperedException {
110-
// Decode token
111-
final String decoded = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
112-
final String[] parts = decoded.split(":");
113-
if (parts.length != 3) throw new TokenInvalidException("Token does not have 3 parts");
114-
115-
// Get casted value and timestamp
116-
final String value = parts[0];
117-
if (value == null) throw new TokenInvalidException("Value is null");
118-
final Optional<Long> timestamp = Mapper.toLong(parts[1]);
119-
if (!timestamp.isPresent()) throw new TokenInvalidException("Timestamp is not a valid long");
120-
121-
// Check age
122-
if (maxAge != null && System.currentTimeMillis() - timestamp.get() > maxAge.toMillis()) throw new TokenExpiredException();
123-
124-
// Recompute signature
125-
final String payload = parts[0] + ":" + parts[1];
126-
final byte[] expectedSig = getSignature(payload);
127-
if (expectedSig == null) return null; // Should never happen
128-
final byte[] actualSig = Base64.getUrlDecoder().decode(parts[2]);
129-
if (!Arrays.equals(expectedSig, actualSig)) throw new TokenTamperedException();
130-
131-
return value;
150+
public JsonElement decrypt(@NotNull String token) throws TokenExpiredException, TokenInvalidException {
151+
// Validate token format
152+
if (token.isEmpty()) throw new TokenInvalidException("Token is empty or invalid");
153+
154+
// Decode Base64 URL-safe string
155+
final byte[] decoded = Base64.getUrlDecoder().decode(token);
156+
final ByteBuffer buffer = ByteBuffer.wrap(decoded);
157+
158+
// Get IV from beginning of buffer
159+
final byte[] iv = new byte[IV_SIZE];
160+
buffer.get(iv);
161+
if (buffer.remaining() < 1) throw new TokenInvalidException("Token is invalid or tampered with, no cipherText found");
162+
163+
// Extract cipherText
164+
final byte[] cipherText = new byte[buffer.remaining()];
165+
buffer.get(cipherText);
166+
167+
// Decrypt cipherText
168+
final byte[] decrypted = getCipherText(Cipher.DECRYPT_MODE, iv, cipherText);
169+
if (decrypted == null) throw new TokenInvalidException("Decryption failed, token is invalid or tampered with");
170+
final String decryptedString = new String(decrypted, StandardCharsets.UTF_8);
171+
172+
// Parse JSON
173+
final JsonObject jsonObject = Mapper.toJson(decryptedString)
174+
.flatMap(element -> Mapper.convertJsonElement(element, JsonObject.class))
175+
.orElseThrow(() -> new TokenInvalidException("Decrypted token is not a valid JSON object"));
176+
if (!jsonObject.has("timestamp") || !jsonObject.has("value")) throw new TokenInvalidException("Decrypted token is missing required fields");
177+
178+
// Check timestamp expiration
179+
final String timestamp = Mapper.convertJsonElement(jsonObject.get("timestamp"), JsonPrimitive.class)
180+
.flatMap(primitive -> Mapper.convertJsonPrimitive(primitive, String.class))
181+
.orElseThrow(() -> new TokenInvalidException("Timestamp is not a valid string"));
182+
final Long timestampValue = Mapper.toLong(timestamp).orElseThrow(() -> new TokenInvalidException("Timestamp is not a valid long value"));
183+
if (maxAge != null && System.currentTimeMillis() - timestampValue > maxAge.toMillis()) throw new TokenExpiredException();
184+
185+
// Return value
186+
final JsonElement value = jsonObject.get("value");
187+
return value.isJsonNull() ? null : value;
132188
}
133189
}

src/main/java/xyz/srnyx/javautilities/objects/encryptor/exceptions/TokenTamperedException.java

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)