Moderate severityOSV Advisory· Published Jan 13, 2026· Updated Jan 13, 2026
Jervis has a JWT Algorithm Confusion Vulnerability
CVE-2025-68925
Description
Jervis is a library for Job DSL plugin scripts and shared Jenkins pipeline libraries. Prior to 2.2, the code doesn't validate that the JWT header specifies "alg":"RS256". This vulnerability is fixed in 2.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
net.gleske:jervisMaven | < 2.2 | 2.2 |
Affected products
1- Range: jervis-0.1, jervis-0.10, jervis-0.11, …
Patches
1c3981ff71de7Merge branch 'advisory-fix-1'
5 files changed · +728 −109
CHANGELOG.md+22 −0 modified@@ -3,6 +3,28 @@ This file contains all of the notable changes from Jervis releases. For the full change log see the commit log. +# jervis 2.2 + +## Bug fixes + +- SecurityIO.sha256sum padding bug fixed. +- SecurityIO switching to `AES/GCM/NoPadding` and old AES functions marked as + deprecated to be removed in Jervis 2.3. +- SecurityIO.avoidTimingAttack uses cryptographically secure PRNG. +- SecurityIO.verifyJsonWebToken does additional JWT structure validation. +- SecurityIO.isBase64 now checks for base64 padding. Previously, it was + possible for a String to pass as base64 characters but not be actually valid + base64. +- SecurityIO.sha256Sum hex string padding fixed so resulting checksum is always + valid for 3rd party checksum functions. + +## Breaking changes + +- `CipherMap` encrypted data will be discarded when upgrading to Jervis 2.2. + Currently, this only affects GitHub app authentication in Jervis and new + tokens will be issued instead of reusing old tokens within their previous + expiration. In general, GitHub app issued tokens expire after one hour. + # jervis 2.1 ## Bug fixes
src/main/groovy/net/gleske/jervis/tools/CipherMap.groovy+50 −80 modified@@ -42,29 +42,20 @@ import javax.crypto.BadPaddingException <ul> <li> - Upon instantiation the AES cipher secret and initialization vector (IV) are - randomly generated bytes. The random secret is 32 bytes and the random IV - is 16 bytes. + Upon instantiation the AES cipher secret is randomly generated (32 bytes). + A random 12-byte nonce is generated for each encryption operation. </li> <li> - The cipher secret and IV are asymmetrically encrypted with RSA. The - stronger the RSA key provided the more secure the encryption at rest. + The cipher secret is asymmetrically encrypted with RSA using OAEP padding. + OAEP padding prevents Bleichenbacher padding oracle attacks. The stronger + the RSA key provided the more secure the encryption at rest. Keys below 2048-bits will throw an exception for being too weak. Recommended RSA private key size is 4096-bit. </li> <li> - The data is encrypted with AES-256 CBC with PKCS5 padding. The cipher - secret and IV are the inputs for encryption and decryption. - </li> - <li> - The random IV is hashed with 5000 iterations of SHA-256 (let's call this - initialization hash) and the resulting hash is passed into PBKDF2 with Hmac - SHA-256 (PBKDF for short). The PBKDF has variable iterations based on the - provided initialization hash. The iterations for PBKDF range from 100100 - to 960000 iterations. Since this is configurable via - <tt>{@link #hash_iterations}</tt> it's possible to fully disable this - behavior (setting <tt>hash_iterations</tt> to zero) and only use the - provided secret and IV unmodified. + The data is encrypted with AES-256-GCM authenticated encryption. + GCM mode provides both confidentiality and integrity protection, + preventing padding oracle attacks and detecting tampering. </li> <li> After encryption, the encrypted value is signed with an RS256 signature @@ -73,10 +64,9 @@ import javax.crypto.BadPaddingException An empty map is the default if the signature is invalid. </li> <li> - The cipher secret and IV infrequently change to protect against RSA attack - utilizing Chinese Remainder Theorem. The cipher secret and IV are - automatically rotated if the secrets (secret/IV) are older than 30 days - when data is encrypted. + The cipher secret infrequently changes to protect against RSA attack + utilizing Chinese Remainder Theorem. The cipher secret is automatically + rotated if the secret is older than 30 days when data is encrypted. </li> </ul> @@ -113,15 +103,7 @@ timing = time { println("Time to load from String and decrypt: ${timing} second(s)") -// re-encrypt with stronger security -def cmap3 = new CipherMap(new File('src/test/resources/rsa_keys/good_id_rsa_4096').text) -cmap3.hash_iterations = 100100 - -timing = time { - cmap3.plainMap = cmap1.plainMap -} -println("Time migrating to stronger encryption with 100100 hash iterations: ${timing} second(s)") -println(['\n', '='*80, 'Encrypted contents with CipherMap toString()'.with { ' '*(40 - it.size()/2) + it }, '='*80, "\n${cmap3}"].join('\n')) +println(['\n', '='*80, 'Encrypted contents with CipherMap toString()'.with { ' '*(40 - it.size()/2) + it }, '='*80, "\n${cmap1}"].join('\n')) </code></pre> */ class CipherMap implements Serializable { @@ -136,16 +118,17 @@ class CipherMap implements Serializable { private transient Map hidden /** - Customize the number of SHA-256 hash iterations performed during AES - encryption operations. + Deprecated: This field is no longer used. AES-256-GCM mode uses random + nonces generated for each encryption operation instead of derived IVs. - @see net.gleske.jervis.tools.SecurityIO#DEFAULT_AES_ITERATIONS + @deprecated No longer used with AES-256-GCM authenticated encryption. */ + @Deprecated Integer hash_iterations /** - The time limit in seconds before AES secret and IV need to be rotated. - Once it reaches this age then new secrets will be generated. Default: + The time limit in seconds before AES secret needs to be rotated. + Once it reaches this age then a new secret will be generated. Default: <tt>2592000</tt> seconds (number of seconds in 30 days). @see #setPlainMap(java.util.Map) @@ -159,17 +142,18 @@ class CipherMap implements Serializable { @param privateKey A PKCS1 or PKCS8 private key PEM. */ CipherMap(String privateKey) { - this(privateKey, SecurityIO.DEFAULT_AES_ITERATIONS) + this.security = new SecurityIO(privateKey) } /** Instantiates a new CipherMap object with the given private key. This is used for asymmetric encryption wrapping symmetric encryption. - @see #hash_iterations + @deprecated The hash_iterations parameter is no longer used with AES-256-GCM. @param privateKey A PKCS1 or PKCS8 private key PEM. - @param hash_iterations Customize the hash iterations on instantiation. + @param hash_iterations Deprecated, ignored. AES-GCM uses random nonces. */ + @Deprecated CipherMap(String privateKey, Integer hash_iterations) { this.hash_iterations = hash_iterations this.security = new SecurityIO(privateKey) @@ -189,52 +173,47 @@ class CipherMap implements Serializable { Instantiates a new CipherMap object with the given private key. This is used for asymmetric encryption wrapping symmetric encryption. - @see #hash_iterations + @deprecated The hash_iterations parameter is no longer used with AES-256-GCM. @param privateKey A PKCS1 or PKCS8 private key. - @param hash_iterations Customize the hash iterations on instantiation. + @param hash_iterations Deprecated, ignored. AES-GCM uses random nonces. */ + @Deprecated CipherMap(File privateKey, Integer hash_iterations) { this(privateKey.text, hash_iterations) } /** - Encrypts the data with AES. + Encrypts the data with AES-256-GCM authenticated encryption. @param data To be encrypted. @return Returns encrypted String. */ private String encrypt(String data) { - this.hidden.cipher.with { secret -> - return security.encryptWithAES256Base64( - security.encodeBase64(security.rsaDecryptBytes(security.decodeBase64Bytes(secret[0]))), - security.encodeBase64(security.rsaDecryptBytes(security.decodeBase64Bytes(secret[1]))), - data, - this.hash_iterations - ) - } + String encryptedSecret = this.hidden.cipher + // Decrypt the RSA-wrapped AES secret using OAEP padding + byte[] aesSecret = security.rsaDecryptBytesOaep(security.decodeBase64Bytes(encryptedSecret)) + // Encrypt data with AES-256-GCM (nonce is generated automatically and prepended) + SecurityIO.encryptWithAES256GCMBase64(security.encodeBase64(aesSecret), data) } /** - Decrypts the data with AES. + Decrypts the data with AES-256-GCM authenticated decryption. @param data To be decrypted. @return Returns the plaintext data. */ private String decrypt(String data) { - this.hidden.cipher.with { secret -> - return security.decryptWithAES256Base64( - security.encodeBase64(security.rsaDecryptBytes(security.decodeBase64Bytes(secret[0]))), - security.encodeBase64(security.rsaDecryptBytes(security.decodeBase64Bytes(secret[1]))), - data, - this.hash_iterations - ) - } + String encryptedSecret = this.hidden.cipher + // Decrypt the RSA-wrapped AES secret using OAEP padding + byte[] aesSecret = security.rsaDecryptBytesOaep(security.decodeBase64Bytes(encryptedSecret)) + // Decrypt data with AES-256-GCM + SecurityIO.decryptWithAES256GCMBase64(security.encodeBase64(aesSecret), data) } /** Returns a string meant for signing and verifying signed data. */ private String signedData(Map obj) { - ([obj.age] + obj.cipher + [obj.data]).join('\n') + [obj.age, obj.cipher, obj.data].join('\n') } private Boolean verifyCipherObj(def obj) { @@ -245,13 +224,10 @@ class CipherMap implements Serializable { if(!(['age', 'cipher', 'data', 'signature'] == obj.keySet().toList())) { return false } - if(!((obj.cipher in List) && obj.cipher.size() == 2)) { - return false - } + // cipher is now a single String (RSA-OAEP encrypted AES secret) Boolean stringCheck = [ obj.age, - obj.cipher[0], - obj.cipher[1], + obj.cipher, obj.data, obj.signature ].every { it in String } @@ -268,23 +244,19 @@ class CipherMap implements Serializable { } /** - Creates a new cipher either for the first time or as part of rotation. - @return Returns a new random cipher secret and IV. + Creates a new cipher secret using RSA-OAEP encryption. + @return Returns a new RSA-OAEP encrypted random AES-256 secret. */ - private List newCipher() { - // Get the max size an RSA key can encrypt. Sometimes this is less - // than 256 bytes which means padding will be required. - Integer maxSize = [((security.getRsa_keysize() / 8) - 11), 256].min() - [ - security.encodeBase64(security.rsaEncryptBytes(security.randomBytes(maxSize))), - security.encodeBase64(security.rsaEncryptBytes(security.randomBytes(16))) - ] + private String newCipher() { + // Generate a 32-byte (256-bit) random AES secret + // Use OAEP padding for RSA encryption to prevent Bleichenbacher attacks + security.encodeBase64(security.rsaEncryptBytesOaep(SecurityIO.randomBytes(32))) } private void initialize() { this.hidden = [ age: '', - cipher: ['', ''], + cipher: '', data: '', signature: '' ] @@ -384,11 +356,9 @@ class CipherMap implements Serializable { Returns an encrypted object as text meant for storing at rest. <pre><code> -age: AES encrypted timestamp -cipher: - - asymmetrically encrypted AES secret - - asymmetrically encrypted AES IV -data: AES encrypted data +age: AES-GCM encrypted timestamp +cipher: RSA-OAEP encrypted AES-256 secret +data: AES-GCM encrypted data (with nonce prepended) signature: RS256 Base64URL signature. </code></pre>
src/main/groovy/net/gleske/jervis/tools/SecurityIO.groovy+307 −6 modified@@ -20,6 +20,7 @@ import net.gleske.jervis.exceptions.EncryptException import net.gleske.jervis.exceptions.KeyPairDecodeException import groovy.json.JsonBuilder +import java.io.ByteArrayOutputStream import java.security.KeyFactory import java.security.KeyPair import java.security.MessageDigest @@ -30,16 +31,19 @@ import java.security.spec.KeySpec import java.security.spec.PKCS8EncodedKeySpec import java.time.Duration import java.time.Instant +import java.util.Arrays import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec import org.bouncycastle.asn1.ASN1Encodable import org.bouncycastle.asn1.ASN1Primitive import org.bouncycastle.asn1.pkcs.PrivateKeyInfo import org.bouncycastle.crypto.AsymmetricBlockCipher +import org.bouncycastle.crypto.encodings.OAEPEncoding import org.bouncycastle.crypto.encodings.PKCS1Encoding import org.bouncycastle.crypto.engines.RSAEngine import org.bouncycastle.crypto.params.AsymmetricKeyParameter @@ -243,8 +247,21 @@ if(security.verifyGitHubJWTPayload(jwt)) { */ Boolean verifyJsonWebToken(String github_jwt) { List jwt = github_jwt.tokenize('.') + // Validate JWT structure: must have exactly 3 parts (header.payload.signature) + if(jwt.size() != 3) { + return false + } + // Validate algorithm to prevent algorithm confusion attacks + try { + Map header = YamlOperator.loadYamlFrom(decodeBase64UrlBytes(jwt[0])) + if(header.alg != 'RS256') { + return false + } + } catch(Exception e) { + return false + } String data = jwt[0..1].join('.') - String signature = jwt[-1] + String signature = jwt[2] verifyRS256Base64Url(signature, data) } @@ -434,6 +451,65 @@ println "Key size: ${security.rsa_keysize}" encodeBase64(content).tr('+/', '-_') } + /** + Uses RSA asymmetric encryption with OAEP padding to encrypt a plain text <tt>String</tt>. + OAEP padding is recommended over PKCS1 padding to prevent Bleichenbacher attacks. + + @param plaintext A plain text <tt>String</tt> to be encrypted. + @return A Base64 encoded ciphertext. + */ + String rsaEncryptOaep(String plaintext) throws EncryptException { + byte[] ciphertext = rsaEncryptBytesOaep(plaintext.bytes) + encodeBase64(ciphertext) + } + + /** + Uses RSA asymmetric encryption with OAEP padding to encrypt plain bytes. + OAEP padding is recommended over PKCS1 padding to prevent Bleichenbacher attacks. + + @param plainbytes Plain bytes to be encrypted. + @return Enciphered bytes are returned. + */ + byte[] rsaEncryptBytesOaep(byte[] plainbytes) throws EncryptException { + if(!key_pair) { + throw new EncryptException('key_pair is not set.') + } + // Use OAEP padding instead of PKCS1 to prevent Bleichenbacher padding oracle attacks + AsymmetricBlockCipher encrypt = new OAEPEncoding(new RSAEngine()) + encrypt.init(true, PublicKeyFactory.createKey(key_pair.public.encoded) as AsymmetricKeyParameter) + byte[] enciphered = encrypt.processBlock(plainbytes, 0, plainbytes.length) + enciphered + } + + /** + Uses RSA asymmetric encryption with OAEP padding to decrypt a ciphertext <tt>String</tt>. + OAEP padding is recommended over PKCS1 padding to prevent Bleichenbacher attacks. + + @param ciphertext A Base64 encoded ciphertext <tt>String</tt> to be decrypted. + @return A plain text <tt>String</tt>. + */ + String rsaDecryptOaep(String ciphertext) throws DecryptException { + byte[] messageBytes = decodeBase64Bytes(ciphertext) + (new String(rsaDecryptBytesOaep(messageBytes))) + } + + /** + Uses RSA asymmetric encryption with OAEP padding to decrypt enciphered bytes. + OAEP padding is recommended over PKCS1 padding to prevent Bleichenbacher attacks. + + @param cipherbytes Encrypted bytes. + @return Returns decrypted bytes. + */ + byte[] rsaDecryptBytesOaep(byte[] cipherbytes) throws DecryptException { + if(!key_pair) { + throw new DecryptException('key_pair is not set.') + } + // Use OAEP padding instead of PKCS1 to prevent Bleichenbacher padding oracle attacks + AsymmetricBlockCipher decrypt = new OAEPEncoding(new RSAEngine()) + decrypt.init(false, PrivateKeyFactory.createKey(key_pair.private.encoded) as AsymmetricKeyParameter) + decrypt.processBlock(cipherbytes, 0, cipherbytes.length) + } + /** Uses RSA asymmetric encryption to encrypt a plain text <tt>String</tt> and outputs ciphertext. @@ -443,19 +519,26 @@ println "Key size: ${security.rsa_keysize}" echo -n 'plaintext' | openssl rsautl -encrypt -inkey ./id_rsa.pub -pubin | openssl enc -base64 -A </code></pre> + @deprecated Use {@link #rsaEncryptOaep(java.lang.String)} instead. PKCS1 padding is + vulnerable to Bleichenbacher padding oracle attacks. @param plaintext A plain text <tt>String</tt> to be encrypted. @return A Base64 encoded ciphertext or more generically: <tt>ciphertext = base64encode(RSAPublicKeyEncrypt(plaintext))</tt> */ + @Deprecated String rsaEncrypt(String plaintext) throws EncryptException { byte[] ciphertext = rsaEncryptBytes(plaintext.bytes) encodeBase64(ciphertext) } /** Uses RSA asymmetric encryption to encrypt a plain bytes and outputs enciphered bytes. + + @deprecated Use {@link #rsaEncryptBytesOaep(byte[])} instead. PKCS1 padding is + vulnerable to Bleichenbacher padding oracle attacks. @param plainbytes Plain bytes to be encrypted. @return Enciphered bytes are returned. */ + @Deprecated byte[] rsaEncryptBytes(byte[] plainbytes) throws EncryptException { if(!key_pair) { throw new EncryptException('key_pair is not set.') @@ -475,19 +558,26 @@ echo -n 'plaintext' | openssl rsautl -encrypt -inkey ./id_rsa.pub -pubin | opens echo 'ciphertext' | openssl enc -base64 -A -d | openssl rsautl -decrypt -inkey /tmp/id_rsa </code></pre> + @deprecated Use {@link #rsaDecryptOaep(java.lang.String)} instead. PKCS1 padding is + vulnerable to Bleichenbacher padding oracle attacks. @param ciphertext A Base64 encoded ciphertext <tt>String</tt> to be decrypted. @return A plain text <tt>String</tt> or more generically: <tt>plaintext = RSAPrivateKeyDecrypt(base64decode(ciphertext))</tt> */ + @Deprecated String rsaDecrypt(String ciphertext) throws DecryptException { byte[] messageBytes = decodeBase64Bytes(ciphertext) (new String(rsaDecryptBytes(messageBytes))) } /** Uses RSA asymmetric encryption to decrypt enciphered bytes and returns plain bytes. + + @deprecated Use {@link #rsaDecryptBytesOaep(byte[])} instead. PKCS1 padding is + vulnerable to Bleichenbacher padding oracle attacks. @param cipherbytes Encrypted bytes. @return Returns decrypted bytes. */ + @Deprecated byte[] rsaDecryptBytes(byte[] cipherbytes) throws DecryptException { if(!key_pair) { throw new DecryptException('key_pair is not set.') @@ -591,7 +681,8 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") @return Returns the result from the executed closure. */ static def avoidTimingAttack(Integer milliseconds, Closure body) { - Long desiredTime = (milliseconds < 0) ? (new Random().nextInt(milliseconds * -1)) : milliseconds + // Use SecureRandom instead of Random for cryptographically secure randomness + Long desiredTime = (milliseconds < 0) ? (SecureRandom.getInstance('NativePRNGNonBlocking').nextInt(milliseconds * -1)) : milliseconds Long before = Instant.now().toEpochMilli() // execute code def result = body() @@ -622,7 +713,7 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") static String sha256Sum(byte[] input) { MessageDigest digest = MessageDigest.getInstance('SHA-256') digest.update(input) - new BigInteger(1,digest.digest()).toString(16).padLeft(32, '0') + new BigInteger(1,digest.digest()).toString(16).padLeft(64, '0') } /** @@ -647,10 +738,101 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") encodeBase64(randomBytes(size)) } + /** + Encrypt plaintext using AES-256 GCM mode with authenticated encryption. + Uses a random 12-byte nonce which is prepended to the ciphertext. + GCM mode provides both confidentiality and authenticity. + + @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-94225C88-F2F1-44D1-A781-1DD9D5094566" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>Cipher</tt> class</a> + @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html" target=_blank>Java Security Standard Algorithm Names</a>. + @param secret Secret key for encrypting. If byte-count is less than 32 + (256-bits), then bytes are repeated until 256 bits are available. + @param data Data to be encrypted with AES-256-GCM. + @return Ciphertext with 12-byte nonce prepended (nonce || ciphertext || auth-tag). + */ + static byte[] encryptWithAES256GCM(byte[] secret, String data) { + // Generate a random 12-byte nonce for GCM (recommended size) + byte[] nonce = randomBytes(12) + + // 32 comes from 256 / 8 in AES-256 + SecretKey key = new SecretKeySpec(padForAES256(secret), 0, 32, 'AES') + // Use AES-GCM for authenticated encryption (prevents padding oracle attacks) + Cipher cipher = Cipher.getInstance('AES/GCM/NoPadding') + GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce) // 128-bit auth tag + cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec) + byte[] ciphertext = cipher.doFinal(data.getBytes('UTF-8')) + + // Prepend nonce to ciphertext (nonce || ciphertext) + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + outputStream.write(nonce) + outputStream.write(ciphertext) + outputStream.toByteArray() + } + + /** + Decrypt ciphertext using AES-256 GCM mode with authenticated decryption. + Expects the nonce (12 bytes) to be prepended to the ciphertext. + GCM mode provides both confidentiality and authenticity verification. + + @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-94225C88-F2F1-44D1-A781-1DD9D5094566" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>Cipher</tt> class</a> + @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html" target=_blank>Java Security Standard Algorithm Names</a>. + @param secret Secret key for decrypting. If byte-count is less than 32 + (256-bits), then bytes are repeated until 256 bits are available. + @param data Data to be decrypted with AES-256-GCM. Must have 12-byte nonce prepended. + @return Decrypted plaintext String. + */ + static String decryptWithAES256GCM(byte[] secret, byte[] data) { + // Extract 12-byte nonce from beginning of ciphertext + if(data.length < 28) { // 12 bytes nonce + 16 bytes auth tag (min for empty plaintext) + throw new DecryptException('Ciphertext too short - missing nonce or auth tag') + } + byte[] nonce = Arrays.copyOfRange(data, 0, 12) + byte[] ciphertext = Arrays.copyOfRange(data, 12, data.length) + + // 32 comes from 256 / 8 in AES-256 + SecretKey key = new SecretKeySpec(padForAES256(secret), 0, 32, 'AES') + // Use AES-GCM for authenticated decryption + Cipher cipher = Cipher.getInstance('AES/GCM/NoPadding') + GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce) // 128-bit auth tag + cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) + new String(cipher.doFinal(ciphertext), 'UTF-8') + } + + /** + Takes Base64 encoded secret and encrypts the provided <tt>String</tt> data + using AES-256-GCM authenticated encryption. + + @see #encryptWithAES256GCM(byte[], java.lang.String) + @param secret Base64 encoded binary secret. + @param data A plain <tt>String</tt> to be encrypted (not Base64 encoded). + @return Base64 encoded ciphertext with nonce prepended. + */ + static String encryptWithAES256GCMBase64(String secret, String data) { + byte[] b_secret = decodeBase64Bytes(secret) + encodeBase64(encryptWithAES256GCM(b_secret, data)) + } + + /** + Takes Base64 encoded secret and decrypts the provided Base64 encoded + ciphertext using AES-256-GCM authenticated decryption. + + @see #decryptWithAES256GCM(byte[], byte[]) + @param secret Base64 encoded binary secret. + @param data Base64 encoded encrypted bytes to decrypt (with nonce prepended). + @return A <tt>String</tt> which is plaintext. + */ + static String decryptWithAES256GCMBase64(String secret, String data) { + byte[] b_secret = decodeBase64Bytes(secret) + byte[] b_data = decodeBase64Bytes(data) + decryptWithAES256GCM(b_secret, b_data) + } + /** Encrypt plaintext using AES-256 CBC mode with PKCS5 padding. The IV is hashed with multiple iterations of SHA-256. + @deprecated Use {@link #encryptWithAES256GCM(byte[], java.lang.String)} instead. + AES-CBC is vulnerable to padding oracle attacks and lacks authentication. @see #DEFAULT_AES_ITERATIONS @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-94225C88-F2F1-44D1-A781-1DD9D5094566" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>Cipher</tt> class</a> @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html" target=_blank>Java Security Standard Algorithm Names</a>. @@ -667,6 +849,7 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") original <tt>iv</tt> is used for AES cipher initialization. */ + @Deprecated static byte[] encryptWithAES256(byte[] secret, byte[] iv, String data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { // Calculate IV with 5k iterations of SHA-256 sum String checksum @@ -689,6 +872,8 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") Decrypt ciphertext using AES-256 CBC mode with PKCS5 padding. The IV is hashed with multiple iterations of SHA-256. + @deprecated Use {@link #decryptWithAES256GCM(byte[], byte[])} instead. + AES-CBC is vulnerable to padding oracle attacks and lacks authentication. @see #DEFAULT_AES_ITERATIONS @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-94225C88-F2F1-44D1-A781-1DD9D5094566" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>Cipher</tt> class</a> @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html" target=_blank>Java Security Standard Algorithm Names</a>. @@ -705,6 +890,7 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") original <tt>iv</tt> is used for AES cipher initialization. */ + @Deprecated static String decryptWithAES256(byte[] secret, byte[] iv, byte[] data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { // Calculate IV with 5k iterations of SHA-256 sum String checksum @@ -727,11 +913,14 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") Takes Base64 encoded secret and IV and encrypts the provided <tt>String</tt> data. + @deprecated Use {@link #encryptWithAES256GCMBase64(java.lang.String, java.lang.String)} instead. + AES-CBC is vulnerable to padding oracle attacks and lacks authentication. @see #encryptWithAES256(byte[], byte[], java.lang.String, java.lang.Integer) @param secret Base64 encoded binary secret. @param iv Base64 encoded binary initialization vector (IV). @param data A plain <tt>String</tt> to be encrypted (not Base64 encoded). */ + @Deprecated static String encryptWithAES256Base64(String secret, String iv, String data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { byte[] b_secret = decodeBase64Bytes(secret) byte[] b_iv = decodeBase64Bytes(iv) @@ -742,12 +931,15 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") Takes Base64 encoded secret and IV and decrypts the provided Base64 encoded <tt>String</tt> data. + @deprecated Use {@link #decryptWithAES256GCMBase64(java.lang.String, java.lang.String)} instead. + AES-CBC is vulnerable to padding oracle attacks and lacks authentication. @see #encryptWithAES256(byte[], byte[], java.lang.String, java.lang.Integer) @param secret Base64 encoded binary secret. @param iv Base64 encoded binary initialization vector (IV). @param data Base64 encoded encrypted bytes to decrypt. @return A <tt>String</tt> which is plaintext. */ + @Deprecated static String decryptWithAES256Base64(String secret, String iv, String data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { byte[] b_secret = decodeBase64Bytes(secret) byte[] b_iv = decodeBase64Bytes(iv) @@ -814,6 +1006,98 @@ println("Time taken (milliseconds): ${Instant.now().toEpochMilli() - before}ms") factory.generateSecret(spec).getEncoded() } + /** + Encrypts String data with AES-256-GCM using a passphrase. + Uses a random 32-byte salt which is prepended to the ciphertext. + Provides authenticated encryption for both confidentiality and integrity. + + <h2>Sample usage</h2> +<pre><code> +import net.gleske.jervis.tools.SecurityIO +import java.time.Instant + +Long time(Closure c) { + Long before = Instant.now().epochSecond + c() + Long after = Instant.now().epochSecond + after - before +} + +String encrypted +Long timing1 = time { + encrypted = SecurityIO.encryptWithPassphraseGCM('correct horse battery staple', 'My secret data') +} + +println("Encrypted text: ${encrypted}") +Long timing2 = time { + println("Decrypted text: ${SecurityIO.decryptWithPassphraseGCM('correct horse battery staple', encrypted)}") +} + +println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" +</code></pre> + + @see #encryptWithAES256GCM(byte[], java.lang.String) encryptWithAES256GCM AES-GCM enciphering details + @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-5E8F4099-779F-4484-9A95-F1CEA167601A" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>SecretKeyFactory</tt> class</a> + @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#secretkeyfactory-algorithms" target=_blank><tt>SecretKeyFactory</tt> algorithm names</a> + @see <a href="https://www.nist.gov/publications/recommendation-password-based-key-derivation-part-1-storage-applications" target=_blank>Recommendation for Password-Based Key Derivation Part 1: Storage Applications</a>; + Published: December 22, 2010; Citation: Special Publication (NIST SP) - 800-132 + + @param passphrase A passphrase used to generate AES keys for encryption. + The passphrase creates an AES key using + <tt>PBKDF2WithHmacSHA256</tt>, a random 32-byte salt, + and variable PBKDF2 iterations based on passphrase checksum. + The PBKDF2 iterations are between <tt>100100</tt> and <tt>960000</tt>. + The random salt is prepended to the ciphertext. + @param data Data to be encrypted. + @return Base64 encoded ciphertext with format: salt (32 bytes) || nonce (12 bytes) || ciphertext || auth-tag + */ + static String encryptWithPassphraseGCM(String passphrase, String data) { + // Generate a random 32-byte salt (prevents rainbow table attacks) + byte[] randomSalt = randomBytes(32) + String saltHex = encodeBase64(randomSalt) + + // Derive key using random salt + byte[] b_secret = passwordKeyDerivation(passphrase, saltHex) + + // Encrypt with AES-256-GCM + byte[] ciphertext = encryptWithAES256GCM(b_secret, data) + + // Prepend salt to ciphertext: salt (32 bytes) || nonce || ciphertext + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + outputStream.write(randomSalt) + outputStream.write(ciphertext) + encodeBase64(outputStream.toByteArray()) + } + + /** + Decrypts ciphertext with AES-256-GCM using a passphrase. + Expects the format: salt (32 bytes) || nonce (12 bytes) || ciphertext || auth-tag + Provides authenticated decryption that verifies integrity. + + @see #encryptWithPassphraseGCM(java.lang.String, java.lang.String) encryptWithPassphraseGCM using passphrase method + @see #decryptWithAES256GCM(byte[], byte[]) decryptWithAES256GCM AES-GCM deciphering details + @param passphrase A passphrase used to decrypt AES-256-GCM ciphertext. + @param data Base64 encoded ciphertext to be decrypted (with prepended salt and nonce). + @return Decrypted plaintext String. + */ + static String decryptWithPassphraseGCM(String passphrase, String data) { + byte[] fullData = decodeBase64Bytes(data) + + // Extract 32-byte salt from beginning + if(fullData.length < 60) { // 32 bytes salt + 12 bytes nonce + 16 bytes auth tag (min for empty plaintext) + throw new DecryptException('Ciphertext too short - missing salt, nonce, or auth tag') + } + byte[] randomSalt = Arrays.copyOfRange(fullData, 0, 32) + byte[] ciphertextWithNonce = Arrays.copyOfRange(fullData, 32, fullData.length) + + // Derive key using extracted salt + String saltHex = encodeBase64(randomSalt) + byte[] b_secret = passwordKeyDerivation(passphrase, saltHex) + + // Decrypt with AES-256-GCM + decryptWithAES256GCM(b_secret, ciphertextWithNonce) + } + /** Encrypts String data with AES-256 using a passphrase. @@ -843,6 +1127,9 @@ Long timing2 = time { println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" </code></pre> + @deprecated Use {@link #encryptWithPassphraseGCM(java.lang.String, java.lang.String)} instead. + AES-CBC is vulnerable to padding oracle attacks, lacks authentication, and uses + deterministic IV/salt derived from passphrase. @see #DEFAULT_AES_ITERATIONS @see #encryptWithAES256(byte[], byte[], java.lang.String, java.lang.Integer) encryptWithAES256 AES enciphering details @see <a href="https://docs.oracle.com/en/java/javase/11/security/java-cryptography-architecture-jca-reference-guide.html#GUID-5E8F4099-779F-4484-9A95-F1CEA167601A" target=_blank>Java Cryptography Architecture (JCA) Reference Guide for <tt>SecretKeyFactory</tt> class</a> @@ -863,6 +1150,7 @@ println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" iterations allowed is 1 for password-based encryption. */ + @Deprecated static String encryptWithAES256(String passphrase, String data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { // sha256Sum should always return lower case but forcing toLowerCase // since this is used as an input for encryption and decryption. @@ -877,6 +1165,9 @@ println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" Decrypts ciphertext with AES-256 using a passphrase. See the encrypt method for more details on both algorithms and usage. + @deprecated Use {@link #decryptWithPassphraseGCM(java.lang.String, java.lang.String)} instead. + AES-CBC is vulnerable to padding oracle attacks, lacks authentication, and uses + deterministic IV/salt derived from passphrase. @see #DEFAULT_AES_ITERATIONS @see #encryptWithAES256(java.lang.String, java.lang.String, java.lang.Integer) encryptWithAES256 using passphrase method @see #decryptWithAES256(byte[], byte[], byte[], java.lang.Integer) decryptWithAES256 AES deciphering details @@ -888,6 +1179,7 @@ println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" iterations allowed is 1 for password-based encryption. */ + @Deprecated static String decryptWithAES256(String passphrase, String data, Integer hash_iterations = DEFAULT_AES_ITERATIONS) { // sha256Sum should always return lower case but forcing toLowerCase // since this is used as an input for encryption and decryption. @@ -899,7 +1191,7 @@ println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" decryptWithAES256(b_secret, b_iv, b_data, iterations) } - /* + /** Check if String is valid base64 according to <a href="https://datatracker.ietf.org/doc/html/rfc4648">RFC 4648</a>. @param s Check if String is similar to valid base64 encoding. @@ -909,9 +1201,18 @@ println "Encrypt time: ${timing1} second(s)\nDecrypt time: ${timing2} second(s)" if(s.trim().size() == 0) { return true } - if(s.size() < 4) { + String normalized = s.tokenize('\n').join('').trim() + if(normalized.size() < 4) { + return false + } + // Validate proper base64 format: + // - Must be multiple of 4 characters + // - Only valid base64 characters + // - Padding '=' can only appear at end (max 2) + if(normalized.size() % 4 != 0) { return false } - s.tokenize('\n').join('') =~ /^[=0-9a-zA-Z+\/]+$/ + // Check for valid characters and proper padding placement + normalized =~ /^[0-9a-zA-Z+\/]*={0,2}$/ } }
src/test/groovy/net/gleske/jervis/tools/CipherMapTest.groovy+13 −17 modified@@ -52,19 +52,15 @@ class CipherMapTest extends GroovyTestCase { URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_4096') ciphermap = new CipherMap(new File(url.file)) assert ciphermap.plainMap == [:] - assert ciphermap.hash_iterations == 5000 - ciphermap = new CipherMap(new File(url.file), 3) - assert ciphermap.plainMap == [:] - assert ciphermap.hash_iterations == 3 + // hash_iterations is deprecated and not set by non-deprecated constructor + assert ciphermap.hash_iterations == null } @Test public void test_CipherMap_constructor_string() { URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_4096') ciphermap = new CipherMap(url.content.text) assert ciphermap.plainMap == [:] - assert ciphermap.hash_iterations == 5000 - ciphermap = new CipherMap(url.content.text, 4) - assert ciphermap.plainMap == [:] - assert ciphermap.hash_iterations == 4 + // hash_iterations is deprecated and not set by non-deprecated constructor + assert ciphermap.hash_iterations == null } @Test public void test_CipherMap_leftShift_CipherMap() { ciphermap.plainMap = [hello: 'friend'] @@ -87,6 +83,7 @@ class CipherMapTest extends GroovyTestCase { ciphermap << 'a: b' assert ciphermap.plainMap == [:] + // cipher is now a single string (RSA-OAEP encrypted AES secret) ciphermap.plainMap = [some: 'data'] ciphermap << '''\ age: '' @@ -96,6 +93,7 @@ class CipherMapTest extends GroovyTestCase { '''.stripIndent() assert ciphermap.plainMap == [:] + // cipher as list is now invalid (old format) ciphermap.plainMap = [some: 'data'] ciphermap << '''\ age: 23 @@ -129,8 +127,8 @@ class CipherMapTest extends GroovyTestCase { ciphermap.plainMap = [change: 'data'] Map yaml2 = YamlOperator.loadYamlFrom(ciphermap.toString()) assert yaml1.age == yaml2.age - assert yaml1.cipher[0] == yaml2.cipher[0] - assert yaml1.cipher[1] == yaml2.cipher[1] + // cipher is now a single RSA-OAEP encrypted AES secret + assert yaml1.cipher == yaml2.cipher assert yaml1.data != yaml2.data assert yaml1.signature != yaml2.signature } @@ -141,7 +139,7 @@ class CipherMapTest extends GroovyTestCase { ciphermap.hidden = [data: ''] assert ciphermap.plainMap == [:] } - @Test public void test_CipherMap_rotating_expired_secret_and_iv() { + @Test public void test_CipherMap_rotating_expired_secret() { ciphermap.plainMap = [leeroy: 'jenkins'] // manipulate the encrypted payload to be "older than 30 days" @@ -154,23 +152,21 @@ class CipherMapTest extends GroovyTestCase { assert ciphermap.plainMap == [leeroy: 'jenkins'] Map intermediate = YamlOperator.loadYamlFrom(ciphermap.toString()) assert old.age == intermediate.age - assert old.cipher[0] == intermediate.cipher[0] - assert old.cipher[1] == intermediate.cipher[1] + // cipher is now a single RSA-OAEP encrypted AES secret + assert old.cipher == intermediate.cipher // Encrypt data which should force secrets rotation ciphermap.plainMap = ciphermap.plainMap assert ciphermap.plainMap == [leeroy: 'jenkins'] Map rotated = YamlOperator.loadYamlFrom(ciphermap.toString()) assert old.age != rotated.age - assert old.cipher[0] != rotated.cipher[0] - assert old.cipher[1] != rotated.cipher[1] + assert old.cipher != rotated.cipher // Update encrypted data which should not rotate secrets ciphermap.plainMap = ciphermap.plainMap + [bert: 'ernie'] assert ciphermap.plainMap == [leeroy: 'jenkins', bert: 'ernie'] Map updated = YamlOperator.loadYamlFrom(ciphermap.toString()) assert rotated.age == updated.age - assert rotated.cipher[0] == updated.cipher[0] - assert rotated.cipher[1] == updated.cipher[1] + assert rotated.cipher == updated.cipher } }
src/test/groovy/net/gleske/jervis/tools/SecurityIOTest.groovy+336 −6 modified@@ -199,16 +199,26 @@ class SecurityIOTest extends GroovyTestCase { @Test public void test_SecurityIO_signRS256Base64Url_and_verifyJsonWebToken() { URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') security = new SecurityIO(url.content.text) - String signature = security.signRS256Base64Url('data.data') - String jwt_like = "data.data.${signature}" + // Create a valid JWT header and payload for signature verification + String header = security.encodeBase64Url('{"alg":"RS256","typ":"JWT"}') + String payload = security.encodeBase64Url('{"sub":"test","iat":1234567890}') + String data = "${header}.${payload}" + String signature = security.signRS256Base64Url(data) + String jwt_like = "${data}.${signature}" assert true == security.verifyJsonWebToken(jwt_like) } @Test public void test_SecurityIO_verifyJsonWebToken_fail_to_verify() { URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') security = new SecurityIO(url.content.text) - String signature = security.signRS256Base64Url('data.data') - String jwt_like = "junk.junk.${signature}" + // Create a valid JWT header but sign different data to fail signature verification + String header = security.encodeBase64Url('{"alg":"RS256","typ":"JWT"}') + String payload = security.encodeBase64Url('{"sub":"test","iat":1234567890}') + String data = "${header}.${payload}" + String signature = security.signRS256Base64Url(data) + // Tamper with the payload to cause signature verification failure + String tamperedPayload = security.encodeBase64Url('{"sub":"tampered","iat":1234567890}') + String jwt_like = "${header}.${tamperedPayload}.${signature}" assert false == security.verifyJsonWebToken(jwt_like) } @@ -317,8 +327,14 @@ class SecurityIOTest extends GroovyTestCase { @Test public void test_SecurityIO_verifyGitHubJWTPayload_bad_signature() { URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') security = new SecurityIO(url.content.text) - String signature = security.signRS256Base64Url('data.data') - String jwt_like = "junk.junk.${signature}" + // Create a valid JWT header but sign different data to fail signature verification + String header = security.encodeBase64Url('{"alg":"RS256","typ":"JWT"}') + String payload = security.encodeBase64Url('{"sub":"test","iat":1234567890}') + String data = "${header}.${payload}" + String signature = security.signRS256Base64Url(data) + // Tamper with the payload to cause signature verification failure + String tamperedPayload = security.encodeBase64Url('{"sub":"tampered","iat":1234567890}') + String jwt_like = "${header}.${tamperedPayload}.${signature}" assert false == security.verifyGitHubJWTPayload(jwt_like) } @@ -538,4 +554,318 @@ class SecurityIOTest extends GroovyTestCase { assert SecurityIO.isBase64('YQ\n==\n') == true assert SecurityIO.isBase64('hello world') == false } + + @Test public void test_SecurityIO_isBase64_invalid_characters() { + // Test invalid base64 characters (triggers regex failure) + assert SecurityIO.isBase64('!@#$') == false + assert SecurityIO.isBase64('YQ==!') == false + assert SecurityIO.isBase64('data with spaces') == false + assert SecurityIO.isBase64('====') == false + } + + // ========================================================================== + // Tests for new RSA OAEP encryption methods + // ========================================================================== + + @Test public void test_SecurityIO_rsaEncryptOaep_rsaDecryptOaep() { + String plaintext = 'secret message with OAEP' + String ciphertext + String decodedtext + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security.key_pair = url.content.text + ciphertext = security.rsaEncryptOaep(plaintext) + assert ciphertext.length() > 0 + decodedtext = security.rsaDecryptOaep(ciphertext) + assert plaintext == decodedtext + } + + @Test public void test_SecurityIO_rsaEncryptBytesOaep_rsaDecryptBytesOaep() { + byte[] plainbytes = 'secret bytes with OAEP'.bytes + byte[] cipherbytes + byte[] decodedbytes + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security.key_pair = url.content.text + cipherbytes = security.rsaEncryptBytesOaep(plainbytes) + assert cipherbytes.length > 0 + decodedbytes = security.rsaDecryptBytesOaep(cipherbytes) + assert plainbytes == decodedbytes + } + + @Test public void test_SecurityIO_rsaEncryptOaep_with_4096_key() { + String plaintext = 'secret message with 4096-bit key' + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_4096') + security.key_pair = url.content.text + String ciphertext = security.rsaEncryptOaep(plaintext) + assert ciphertext.length() > 0 + assert plaintext == security.rsaDecryptOaep(ciphertext) + } + + @Test public void test_SecurityIO_fail_rsaEncryptOaep_no_keypair() { + shouldFail(EncryptException) { + security.rsaEncryptOaep('some text') + } + } + + @Test public void test_SecurityIO_fail_rsaDecryptOaep_no_keypair() { + shouldFail(DecryptException) { + security.rsaDecryptOaep('some text') + } + } + + @Test public void test_SecurityIO_fail_rsaEncryptBytesOaep_no_keypair() { + shouldFail(EncryptException) { + security.rsaEncryptBytesOaep('some text'.bytes) + } + } + + @Test public void test_SecurityIO_fail_rsaDecryptBytesOaep_no_keypair() { + shouldFail(DecryptException) { + security.rsaDecryptBytesOaep('some text'.bytes) + } + } + + @Test public void test_SecurityIO_rsaOaep_different_from_pkcs1() { + // Verify OAEP and PKCS1 produce different ciphertext + String plaintext = 'test message' + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security.key_pair = url.content.text + String oaepCiphertext = security.rsaEncryptOaep(plaintext) + String pkcs1Ciphertext = security.rsaEncrypt(plaintext) + // Ciphertexts should be different (different padding schemes) + assert oaepCiphertext != pkcs1Ciphertext + // Both should decrypt to the same plaintext + assert plaintext == security.rsaDecryptOaep(oaepCiphertext) + assert plaintext == security.rsaDecrypt(pkcs1Ciphertext) + } + + // ========================================================================== + // Tests for new AES-256-GCM encryption methods + // ========================================================================== + + @Test public void test_SecurityIO_AES256GCM_encrypt_decrypt() { + byte[] secret = SecurityIO.randomBytes(32) + String plaintext = 'secret message for GCM' + byte[] ciphertext = SecurityIO.encryptWithAES256GCM(secret, plaintext) + assert ciphertext.length > 0 + // GCM ciphertext should be: 12 bytes nonce + plaintext + 16 bytes auth tag + assert ciphertext.length >= 12 + plaintext.bytes.length + 16 + String decrypted = SecurityIO.decryptWithAES256GCM(secret, ciphertext) + assert plaintext == decrypted + } + + @Test public void test_SecurityIO_AES256GCM_random_nonce() { + // Verify each encryption produces different ciphertext (random nonce) + byte[] secret = SecurityIO.randomBytes(32) + String plaintext = 'same message' + byte[] ciphertext1 = SecurityIO.encryptWithAES256GCM(secret, plaintext) + byte[] ciphertext2 = SecurityIO.encryptWithAES256GCM(secret, plaintext) + // Ciphertexts should be different due to random nonce + assert ciphertext1 != ciphertext2 + // But both should decrypt to the same plaintext + assert plaintext == SecurityIO.decryptWithAES256GCM(secret, ciphertext1) + assert plaintext == SecurityIO.decryptWithAES256GCM(secret, ciphertext2) + } + + @Test public void test_SecurityIO_AES256GCM_short_secret() { + // Test with shortened input bytes (should be padded) + byte[] secret = 'short'.bytes + String plaintext = 'some secret message' + byte[] ciphertext = SecurityIO.encryptWithAES256GCM(secret, plaintext) + assert plaintext == SecurityIO.decryptWithAES256GCM(secret, ciphertext) + } + + @Test public void test_SecurityIO_AES256GCM_authentication_failure() { + byte[] secret = SecurityIO.randomBytes(32) + String plaintext = 'secret message' + byte[] ciphertext = SecurityIO.encryptWithAES256GCM(secret, plaintext) + // Tamper with the ciphertext (modify a byte after the nonce) + ciphertext[15] = (byte)(ciphertext[15] ^ 0xFF) + // Decryption should fail due to authentication tag mismatch + shouldFail(javax.crypto.AEADBadTagException) { + SecurityIO.decryptWithAES256GCM(secret, ciphertext) + } + } + + @Test public void test_SecurityIO_AES256GCM_ciphertext_too_short() { + byte[] secret = SecurityIO.randomBytes(32) + // Ciphertext too short (less than nonce + auth tag = 28 bytes minimum) + byte[] shortCiphertext = new byte[27] + shouldFail(DecryptException) { + SecurityIO.decryptWithAES256GCM(secret, shortCiphertext) + } + } + + @Test public void test_SecurityIO_AES256GCM_empty_plaintext() { + byte[] secret = SecurityIO.randomBytes(32) + String plaintext = '' + byte[] ciphertext = SecurityIO.encryptWithAES256GCM(secret, plaintext) + // Ciphertext should be exactly: 12 bytes nonce + 16 bytes auth tag + assert ciphertext.length == 28 + assert plaintext == SecurityIO.decryptWithAES256GCM(secret, ciphertext) + } + + @Test public void test_SecurityIO_AES256GCMBase64_encrypt_decrypt() { + String secretB64 = SecurityIO.randomBytesBase64(32) + String plaintext = 'secret message for GCM Base64' + String ciphertextB64 = SecurityIO.encryptWithAES256GCMBase64(secretB64, plaintext) + assert ciphertextB64.length() > 0 + String decrypted = SecurityIO.decryptWithAES256GCMBase64(secretB64, ciphertextB64) + assert plaintext == decrypted + } + + @Test public void test_SecurityIO_AES256GCMBase64_random_nonce() { + String secretB64 = SecurityIO.randomBytesBase64(32) + String plaintext = 'same message' + String ciphertext1 = SecurityIO.encryptWithAES256GCMBase64(secretB64, plaintext) + String ciphertext2 = SecurityIO.encryptWithAES256GCMBase64(secretB64, plaintext) + // Ciphertexts should be different due to random nonce + assert ciphertext1 != ciphertext2 + // But both should decrypt to the same plaintext + assert plaintext == SecurityIO.decryptWithAES256GCMBase64(secretB64, ciphertext1) + assert plaintext == SecurityIO.decryptWithAES256GCMBase64(secretB64, ciphertext2) + } + + // ========================================================================== + // Tests for new passphrase-based AES-256-GCM encryption methods + // ========================================================================== + + @Test public void test_SecurityIO_encryptWithPassphraseGCM_decryptWithPassphraseGCM() { + String passphrase = 'correct horse battery staple' + String plaintext = 'https://xkcd.com/936/' + String ciphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + assert ciphertext.length() > 0 + String decrypted = SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext) + assert plaintext == decrypted + } + + @Test public void test_SecurityIO_PassphraseGCM_random_salt_and_nonce() { + // Verify each encryption produces different ciphertext (random salt and nonce) + String passphrase = 'my secret passphrase' + String plaintext = 'same message' + String ciphertext1 = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + String ciphertext2 = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + // Ciphertexts should be different due to random salt and nonce + assert ciphertext1 != ciphertext2 + // But both should decrypt to the same plaintext + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext1) + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext2) + } + + @Test public void test_SecurityIO_PassphraseGCM_wrong_passphrase() { + String passphrase = 'correct passphrase' + String wrongPassphrase = 'wrong passphrase' + String plaintext = 'secret data' + String ciphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + // Decryption with wrong passphrase should fail + shouldFail(javax.crypto.AEADBadTagException) { + SecurityIO.decryptWithPassphraseGCM(wrongPassphrase, ciphertext) + } + } + + @Test public void test_SecurityIO_PassphraseGCM_ciphertext_too_short() { + String passphrase = 'some passphrase' + // Ciphertext too short (less than salt + nonce + auth tag = 60 bytes minimum) + String shortCiphertext = SecurityIO.encodeBase64(new byte[59]) + shouldFail(DecryptException) { + SecurityIO.decryptWithPassphraseGCM(passphrase, shortCiphertext) + } + } + + @Test public void test_SecurityIO_PassphraseGCM_authentication_failure() { + String passphrase = 'my passphrase' + String plaintext = 'secret message' + String ciphertextB64 = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + byte[] ciphertext = SecurityIO.decodeBase64Bytes(ciphertextB64) + // Tamper with the ciphertext (modify a byte after salt and nonce) + ciphertext[50] = (byte)(ciphertext[50] ^ 0xFF) + String tamperedB64 = SecurityIO.encodeBase64(ciphertext) + // Decryption should fail due to authentication tag mismatch + shouldFail(javax.crypto.AEADBadTagException) { + SecurityIO.decryptWithPassphraseGCM(passphrase, tamperedB64) + } + } + + @Test public void test_SecurityIO_PassphraseGCM_different_from_CBC() { + // Verify GCM and CBC passphrase encryption produce different results + String passphrase = 'test passphrase' + String plaintext = 'test message' + String gcmCiphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + String cbcCiphertext = SecurityIO.encryptWithAES256(passphrase, plaintext) + // Ciphertexts should be different (different modes and formats) + assert gcmCiphertext != cbcCiphertext + // Both should decrypt to the same plaintext with their respective methods + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, gcmCiphertext) + assert plaintext == SecurityIO.decryptWithAES256(passphrase, cbcCiphertext) + } + + @Test public void test_SecurityIO_PassphraseGCM_empty_plaintext() { + String passphrase = 'some passphrase' + String plaintext = '' + String ciphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + assert ciphertext.length() > 0 + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext) + } + + @Test public void test_SecurityIO_PassphraseGCM_long_plaintext() { + String passphrase = 'some passphrase' + // Create a long plaintext (1000 characters) + String plaintext = 'A' * 1000 + String ciphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext) + } + + @Test public void test_SecurityIO_PassphraseGCM_special_characters() { + String passphrase = 'pässwörd with spëcial çhàracters!' + String plaintext = 'Message with émojis 🔐 and ünïcödé: 日本語' + String ciphertext = SecurityIO.encryptWithPassphraseGCM(passphrase, plaintext) + assert plaintext == SecurityIO.decryptWithPassphraseGCM(passphrase, ciphertext) + } + + // ========================================================================== + // Tests for verifyJsonWebToken edge cases (new validation) + // ========================================================================== + + @Test public void test_SecurityIO_verifyJsonWebToken_invalid_structure_too_few_parts() { + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security = new SecurityIO(url.content.text) + // JWT with only 2 parts should fail + assert false == security.verifyJsonWebToken('header.payload') + } + + @Test public void test_SecurityIO_verifyJsonWebToken_invalid_structure_too_many_parts() { + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security = new SecurityIO(url.content.text) + // JWT with 4 parts should fail + assert false == security.verifyJsonWebToken('a.b.c.d') + } + + @Test public void test_SecurityIO_verifyJsonWebToken_wrong_algorithm() { + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security = new SecurityIO(url.content.text) + // JWT with HS256 algorithm should fail (we only accept RS256) + String header = security.encodeBase64Url('{"alg":"HS256","typ":"JWT"}') + String payload = security.encodeBase64Url('{"sub":"test"}') + String data = "${header}.${payload}" + String signature = security.signRS256Base64Url(data) + assert false == security.verifyJsonWebToken("${data}.${signature}") + } + + @Test public void test_SecurityIO_verifyJsonWebToken_none_algorithm() { + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security = new SecurityIO(url.content.text) + // JWT with "none" algorithm should fail (algorithm confusion attack) + String header = security.encodeBase64Url('{"alg":"none","typ":"JWT"}') + String payload = security.encodeBase64Url('{"sub":"test"}') + assert false == security.verifyJsonWebToken("${header}.${payload}.") + } + + @Test public void test_SecurityIO_verifyJsonWebToken_malformed_header() { + URL url = this.getClass().getResource('/rsa_keys/good_id_rsa_2048') + security = new SecurityIO(url.content.text) + // JWT with malformed header (not valid JSON) should fail + String header = security.encodeBase64Url('not valid json') + String payload = security.encodeBase64Url('{"sub":"test"}') + String signature = 'fakesignature' + assert false == security.verifyJsonWebToken("${header}.${payload}.${signature}") + } }
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
6- github.com/advisories/GHSA-5pq9-5mpr-jj85ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-68925ghsaADVISORY
- github.com/samrocketman/jervis/commit/c3981ff71de7b0f767dfe7b37a2372cb2a51974aghsaWEB
- github.com/samrocketman/jervis/blob/157d2b63ffa5c4bb1d8ee2254950fd2231de2b05/src/main/groovy/net/gleske/jervis/tools/SecurityIO.groovyghsaWEB
- github.com/samrocketman/jervis/commit/c3981ff71de7b0f767dfe7b37a2372cb2a51974aghsax_refsource_MISCWEB
- github.com/samrocketman/jervis/security/advisories/GHSA-5pq9-5mpr-jj85ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.