5050
5151import javax .crypto .BadPaddingException ;
5252import javax .crypto .Cipher ;
53+ import javax .crypto .IllegalBlockSizeException ;
5354import javax .crypto .SecretKey ;
5455import javax .crypto .SecretKeyFactory ;
5556import javax .crypto .spec .GCMParameterSpec ;
@@ -473,14 +474,32 @@ public String decrypt(byte @NotNull[] cipherText)
473474 cipher .init (Cipher .DECRYPT_MODE , _keySpec , _config .createIvSpec (iv ));
474475 return new String (cipher .doFinal (encrypted ), StringUtilsLabKey .DEFAULT_CHARSET );
475476 }
476- catch (BadPaddingException e )
477+ catch (BadPaddingException | IllegalBlockSizeException e )
477478 {
478- // For now, assume that BadPaddingException means the key has been changed and all other
479- // exceptions are coding issues. That might change in the future...
479+ // Decryption failure - likely a bad key or old algorithm.
480480
481- // Track all decryption exceptions that aren't caused by TestCase (below)
481+ // Track all decryption exceptions that aren't caused by TestCase.
482+ // Only the production AES instance (ENCRYPTION_KEY_CHANGED keySource) attempts the fallback;
483+ // migration-temporary instances bypass this block entirely.
482484 if (ENCRYPTION_KEY_CHANGED .equals (_keySource ))
485+ {
486+ // During migration, not-yet-migrated values are in the old format. If a fallback algorithm
487+ // is registered (set by prepareMigrationFallback() when migration is known to be incomplete),
488+ // try it before giving up.
489+ Algorithm fallback = _migrationFallback ;
490+ if (fallback != null )
491+ {
492+ try
493+ {
494+ return fallback .decrypt (cipherText );
495+ }
496+ catch (RuntimeException ignored )
497+ {
498+ // Both algorithms failed; fall through to increment and rethrow
499+ }
500+ }
483501 DECRYPTION_EXCEPTIONS .incrementAndGet ();
502+ }
484503
485504 throw new DecryptionException ("Could not decrypt this content using the " + _keySource , e );
486505 }
@@ -493,6 +512,9 @@ public String decrypt(byte @NotNull[] cipherText)
493512
494513 private static final String ENCRYPTION_KEY_CHANGED = "currently configured EncryptionKey; has the key changed in " + AppProps .getInstance ().getWebappConfigurationFilename () + "?" ;
495514 private static final AtomicInteger DECRYPTION_EXCEPTIONS = new AtomicInteger (0 );
515+ // Set by prepareMigrationFallback() when migration is known to be incomplete; cleared after migration completes.
516+ // Allows HTTP requests to decrypt not-yet-migrated values without failing.
517+ private static volatile Algorithm _migrationFallback = null ;
496518
497519 public static class DecryptionException extends ConfigurationException
498520 {
@@ -539,6 +561,39 @@ static void registerHandler(EncryptionMigrationHandler handler)
539561 void migrateEncryptedContent (String oldPassPhrase , String keySource , AESConfig oldConfig );
540562 }
541563
564+ /**
565+ * Examines the database to determine whether algorithm or key migration is pending, and if so installs a
566+ * fallback algorithm. This allows HTTP requests to transparently decrypt not-yet-migrated values during the
567+ * migration window instead of failing and incrementing DECRYPTION_EXCEPTIONS.
568+ * Must be called after the database and PropertyManager are available (e.g., from CoreModule.afterUpdate()).
569+ * The fallback is cleared automatically once checkMigration() confirms completion.
570+ */
571+ public static void prepareMigrationFallback ()
572+ {
573+ if (!isEncryptionPassPhraseSpecified ())
574+ return ;
575+
576+ String oldPassPhrase = getOldEncryptionPassPhrase ();
577+
578+ String cipher = PropertyManager .getNormalStore ()
579+ .getProperties (ENCRYPTION_CIPHER_CATEGORY )
580+ .get (CIPHER_PROPERTY );
581+
582+ if (oldPassPhrase != null )
583+ {
584+ // Key-change migration not yet complete; use old key. If cipher is also null, old content used the
585+ // legacy cipher (matching what checkMigration() will use: old key + AESConfig.legacy); otherwise
586+ // old content used the current cipher.
587+ AESConfig fallbackConfig = cipher == null ? AESConfig .legacy : AESConfig .current ;
588+ _migrationFallback = new AES (oldPassPhrase , 128 , "legacy key migration fallback" , fallbackConfig );
589+ }
590+ else if (cipher == null )
591+ {
592+ // Cipher migration not yet complete; fall back to legacy cipher with current key
593+ _migrationFallback = new AES (getEncryptionPassPhrase (), 128 , "legacy cipher migration fallback" , AESConfig .legacy );
594+ }
595+ }
596+
542597 public static void checkMigration ()
543598 {
544599 String oldPassPhrase = getOldEncryptionPassPhrase ();
@@ -547,6 +602,7 @@ public static void checkMigration()
547602 if (isEncryptionPassPhraseSpecified () && ModuleLoader .getInstance ().shouldInsertData ())
548603 {
549604 boolean migrationNeeded = false ;
605+ boolean migrationSucceeded = false ;
550606 String keySource = null ;
551607
552608 if (null != oldPassPhrase )
@@ -591,11 +647,16 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
591647
592648 CacheManager .clearAllKnownCaches ();
593649 }
594- // Test to validate conversion and create a validation value if needed
650+ // Test to validate conversion and create a validation value if needed.
651+ // Capture the counter before the test so the save decision is based solely on whether
652+ // this specific test passes, not on concurrent HTTP request decryption failures that may
653+ // have incremented the counter during the (potentially long) migration of auth configurations.
654+ int exceptionsBeforeFinalTest = DECRYPTION_EXCEPTIONS .get ();
595655 testEncryptionKey ();
656+ migrationSucceeded = DECRYPTION_EXCEPTIONS .get () == exceptionsBeforeFinalTest ;
596657 }
597658
598- if (DECRYPTION_EXCEPTIONS . get () == 0 )
659+ if (migrationSucceeded )
599660 {
600661 if (oldPassPhrase != null )
601662 {
@@ -610,6 +671,8 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
610671 }
611672 }
612673 }
674+
675+ _migrationFallback = null ;
613676 }
614677
615678
@@ -663,6 +726,55 @@ public void testBadKeyException()
663726 }
664727 }
665728
729+ @ Test
730+ public void testMigrationFallback ()
731+ {
732+ String text = "test plaintext" ;
733+ AES oldAlgorithm = new AES ("old pass phrase" , 128 , "old algorithm" );
734+ byte [] oldEncrypted = oldAlgorithm .encrypt (text );
735+
736+ // Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED
737+ AES primary = new AES ("primary pass phrase" , 128 , ENCRYPTION_KEY_CHANGED );
738+
739+ // Case 1: no fallback — primary fails and counter increments
740+ int counterBefore = DECRYPTION_EXCEPTIONS .get ();
741+ try
742+ {
743+ primary .decrypt (oldEncrypted );
744+ fail ("Expected DecryptionException" );
745+ }
746+ catch (DecryptionException ignored ) {}
747+ assertEquals (counterBefore + 1 , DECRYPTION_EXCEPTIONS .get ());
748+
749+ // Case 2: correct fallback — transparent success, counter unchanged
750+ _migrationFallback = oldAlgorithm ;
751+ try
752+ {
753+ int counterBeforeFallback = DECRYPTION_EXCEPTIONS .get ();
754+ assertEquals (text , primary .decrypt (oldEncrypted ));
755+ assertEquals ("Counter must not increment when fallback succeeds" , counterBeforeFallback , DECRYPTION_EXCEPTIONS .get ());
756+ }
757+ finally
758+ {
759+ _migrationFallback = null ;
760+ }
761+
762+ // Case 3: wrong fallback — both algorithms fail, counter increments
763+ _migrationFallback = new AES ("wrong pass phrase" , 128 , "wrong fallback" );
764+ int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS .get ();
765+ try
766+ {
767+ primary .decrypt (oldEncrypted );
768+ fail ("Expected DecryptionException" );
769+ }
770+ catch (DecryptionException ignored ) {}
771+ finally
772+ {
773+ _migrationFallback = null ;
774+ }
775+ assertEquals (counterBeforeWrongFallback + 1 , DECRYPTION_EXCEPTIONS .get ());
776+ }
777+
666778 private void test (Algorithm algorithm )
667779 {
668780 test (algorithm , algorithm );
0 commit comments