1
1
package xyz .srnyx .javautilities .objects .encryptor ;
2
2
3
+ import com .google .gson .JsonElement ;
4
+ import com .google .gson .JsonObject ;
5
+ import com .google .gson .JsonPrimitive ;
6
+
3
7
import org .jetbrains .annotations .NotNull ;
4
8
import org .jetbrains .annotations .Nullable ;
5
9
6
10
import xyz .srnyx .javautilities .manipulation .Mapper ;
7
11
import xyz .srnyx .javautilities .objects .encryptor .exceptions .TokenExpiredException ;
8
12
import xyz .srnyx .javautilities .objects .encryptor .exceptions .TokenInvalidException ;
9
- import xyz .srnyx .javautilities .objects .encryptor .exceptions .TokenTamperedException ;
10
13
11
- import javax .crypto .Mac ;
14
+ import javax .crypto .Cipher ;
15
+ import javax .crypto .NoSuchPaddingException ;
16
+ import javax .crypto .spec .GCMParameterSpec ;
12
17
import javax .crypto .spec .SecretKeySpec ;
18
+ import java .nio .ByteBuffer ;
13
19
import java .nio .charset .StandardCharsets ;
20
+ import java .security .InvalidAlgorithmParameterException ;
14
21
import java .security .InvalidKeyException ;
15
22
import java .security .NoSuchAlgorithmException ;
23
+ import java .security .SecureRandom ;
16
24
import java .time .Duration ;
17
- import java .util .Arrays ;
18
25
import java .util .Base64 ;
19
- import java .util .Optional ;
20
26
21
27
22
28
/**
23
29
* Utility class for encrypting and decrypting values using HMAC signatures
24
30
*/
25
31
public class Encryptor {
26
32
/**
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
28
42
*/
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
+
30
57
/**
31
58
* The secret key used for signing
32
59
*/
@@ -40,94 +67,123 @@ public class Encryptor {
40
67
/**
41
68
* Creates a new {@link Encryptor}
42
69
*
43
- * @param algorithm {@link #algorithm}
44
70
* @param secret {@link #secret}
45
71
* @param maxAge {@link #maxAge}
46
72
*
47
73
* @throws NoSuchAlgorithmException if the specified algorithm is not available
48
74
* @throws InvalidKeyException if the provided secret is invalid
75
+ * @throws NoSuchPaddingException if the specified padding scheme is not available
49
76
*/
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 );
53
79
this .maxAge = maxAge ;
54
80
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 ]) );
58
84
}
59
85
60
86
/**
61
- * Generates a signature for the given payload
87
+ * Generates the cipher text for encryption or decryption
62
88
*
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
64
92
*
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
66
94
*/
67
95
@ Nullable
68
- private byte [] getSignature ( @ NotNull String payload ) {
96
+ private byte [] getCipherText ( int mode , byte [] iv , byte [] token ) {
69
97
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 );
73
102
} catch (final Exception e ) {
74
- // This should never happen since we test the Mac instance in the constructor
75
103
e .printStackTrace ();
76
104
return null ;
77
105
}
78
106
}
79
107
80
108
/**
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
83
110
*
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
85
112
*
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
87
114
*/
88
115
@ 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 ());
95
140
}
96
141
97
142
/**
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
101
144
*
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
103
146
*
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
107
148
*/
108
149
@ 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 ;
132
188
}
133
189
}
0 commit comments