VYPR
High severityOSV Advisory· Published Jan 13, 2026· Updated Jan 13, 2026

Jervis has Deterministic AES IV Derivation from Passphrase

CVE-2025-68701

Description

Jervis is a library for Job DSL plugin scripts and shared Jenkins pipeline libraries. Prior to 2.2, Jervis uses deterministic AES IV derivation from a passphrase. This vulnerability is fixed in 2.2.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
net.gleske:jervisMaven
< 2.22.2

Affected products

1

Patches

1
c3981ff71de7

Merge branch 'advisory-fix-1'

https://github.com/samrocketman/jervisSam GleskeJan 11, 2026via ghsa
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

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.