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

Jervis has an RSA PKCS#1 v1.5 Padding Vulnerability

CVE-2025-68698

Description

Jervis is a library for Job DSL plugin scripts and shared Jenkins pipeline libraries. Prior to 2.2, Jervis uses PKCS1Encoding which is vulnerable to Bleichenbacher padding oracle attacks. Modern systems should use OAEP (Optimal Asymmetric Encryption Padding). 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

6

News mentions

0

No linked articles in our index yet.