5959
6060import javax .crypto .BadPaddingException ;
6161import javax .crypto .Cipher ;
62+ import javax .crypto .IllegalBlockSizeException ;
6263import javax .crypto .SecretKey ;
6364import javax .crypto .SecretKeyFactory ;
6465import javax .crypto .spec .GCMParameterSpec ;
@@ -504,14 +505,32 @@ public String decrypt(byte @NotNull[] cipherText)
504505 cipher .init (Cipher .DECRYPT_MODE , _keySpec , _config .createIvSpec (iv ));
505506 return new String (cipher .doFinal (encrypted ), StringUtilsLabKey .DEFAULT_CHARSET );
506507 }
507- catch (BadPaddingException e )
508+ catch (BadPaddingException | IllegalBlockSizeException e )
508509 {
509- // For now, assume that BadPaddingException means the key has been changed and all other
510- // exceptions are coding issues. That might change in the future...
510+ // Decryption failure - likely a bad key or old algorithm.
511511
512- // Track all decryption exceptions that aren't caused by TestCase (below)
512+ // Track all decryption exceptions that aren't caused by TestCase.
513+ // Only the production AES instance (ENCRYPTION_KEY_CHANGED keySource) attempts the fallback;
514+ // migration-temporary instances bypass this block entirely.
513515 if (ENCRYPTION_KEY_CHANGED .equals (_keySource ))
516+ {
517+ // During migration, not-yet-migrated values are in the old format. If a fallback algorithm
518+ // is registered (set by prepareMigrationFallback() when migration is known to be incomplete),
519+ // try it before giving up.
520+ Algorithm fallback = _migrationFallback ;
521+ if (fallback != null )
522+ {
523+ try
524+ {
525+ return fallback .decrypt (cipherText );
526+ }
527+ catch (RuntimeException ignored )
528+ {
529+ // Both algorithms failed; fall through to increment and rethrow
530+ }
531+ }
514532 DECRYPTION_EXCEPTIONS .incrementAndGet ();
533+ }
515534
516535 throw new DecryptionException ("Could not decrypt this content using the " + _keySource , e );
517536 }
@@ -524,6 +543,9 @@ public String decrypt(byte @NotNull[] cipherText)
524543
525544 private static final String ENCRYPTION_KEY_CHANGED = "currently configured EncryptionKey; has the key changed in " + AppProps .getInstance ().getWebappConfigurationFilename () + "?" ;
526545 private static final AtomicInteger DECRYPTION_EXCEPTIONS = new AtomicInteger (0 );
546+ // Set by prepareMigrationFallback() when migration is known to be incomplete; cleared after migration completes.
547+ // Allows HTTP requests to decrypt not-yet-migrated values without failing.
548+ private static volatile Algorithm _migrationFallback = null ;
527549
528550 public static class DecryptionException extends ConfigurationException
529551 {
@@ -574,6 +596,39 @@ static void registerHandler(EncryptionMigrationHandler handler)
574596 void deleteEncryptedContent ();
575597 }
576598
599+ /**
600+ * Examines the database to determine whether algorithm or key migration is pending, and if so installs a
601+ * fallback algorithm. This allows HTTP requests to transparently decrypt not-yet-migrated values during the
602+ * migration window instead of failing and incrementing DECRYPTION_EXCEPTIONS.
603+ * Must be called after the database and PropertyManager are available (e.g., from CoreModule.afterUpdate()).
604+ * The fallback is cleared automatically once checkMigration() confirms completion.
605+ */
606+ public static void prepareMigrationFallback ()
607+ {
608+ if (!isEncryptionPassPhraseSpecified ())
609+ return ;
610+
611+ String oldPassPhrase = getOldEncryptionPassPhrase ();
612+
613+ String cipher = PropertyManager .getNormalStore ()
614+ .getProperties (ENCRYPTION_CIPHER_CATEGORY )
615+ .get (CIPHER_PROPERTY );
616+
617+ if (oldPassPhrase != null )
618+ {
619+ // Key-change migration not yet complete; use old key. If cipher is also null, old content used the
620+ // legacy cipher (matching what checkMigration() will use: old key + AESConfig.legacy); otherwise
621+ // old content used the current cipher.
622+ AESConfig fallbackConfig = cipher == null ? AESConfig .legacy : AESConfig .current ;
623+ _migrationFallback = new AES (oldPassPhrase , 128 , "legacy key migration fallback" , fallbackConfig );
624+ }
625+ else if (cipher == null )
626+ {
627+ // Cipher migration not yet complete; fall back to legacy cipher with current key
628+ _migrationFallback = new AES (getEncryptionPassPhrase (), 128 , "legacy cipher migration fallback" , AESConfig .legacy );
629+ }
630+ }
631+
577632 public static void checkMigration ()
578633 {
579634 String oldPassPhrase = getOldEncryptionPassPhrase ();
@@ -582,6 +637,7 @@ public static void checkMigration()
582637 if (isEncryptionPassPhraseSpecified () && ModuleLoader .getInstance ().shouldInsertData ())
583638 {
584639 boolean migrationNeeded = false ;
640+ boolean migrationSucceeded = false ;
585641 String keySource = null ;
586642
587643 if (null != oldPassPhrase )
@@ -626,11 +682,16 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
626682
627683 CacheManager .clearAllKnownCaches ();
628684 }
629- // Test to validate conversion and create a validation value if needed
685+ // Test to validate conversion and create a validation value if needed.
686+ // Capture the counter before the test so the save decision is based solely on whether
687+ // this specific test passes, not on concurrent HTTP request decryption failures that may
688+ // have incremented the counter during the (potentially long) migration of auth configurations.
689+ int exceptionsBeforeFinalTest = DECRYPTION_EXCEPTIONS .get ();
630690 testEncryptionKey ();
691+ migrationSucceeded = DECRYPTION_EXCEPTIONS .get () == exceptionsBeforeFinalTest ;
631692 }
632693
633- if (DECRYPTION_EXCEPTIONS . get () == 0 )
694+ if (migrationSucceeded )
634695 {
635696 if (oldPassPhrase != null )
636697 {
@@ -643,8 +704,11 @@ else if (!cipher.equals(AESConfig.current.getCipherName()))
643704 cipherProps .save ();
644705 LOG .info ("Migration from existing encrypted content from legacy AES configuration to current AES configuration is complete." );
645706 }
707+ DECRYPTION_EXCEPTIONS .set (0 );
646708 }
647709 }
710+
711+ _migrationFallback = null ;
648712 }
649713
650714 public static void deleteEncryptedContent (User user )
@@ -745,6 +809,55 @@ public void testBadKeyException()
745809 }
746810 }
747811
812+ @ Test
813+ public void testMigrationFallback ()
814+ {
815+ String text = "test plaintext" ;
816+ AES oldAlgorithm = new AES ("old pass phrase" , 128 , "old algorithm" );
817+ byte [] oldEncrypted = oldAlgorithm .encrypt (text );
818+
819+ // Primary (production) instance: different pass phrase, keySource == ENCRYPTION_KEY_CHANGED
820+ AES primary = new AES ("primary pass phrase" , 128 , ENCRYPTION_KEY_CHANGED );
821+
822+ // Case 1: no fallback — primary fails and counter increments
823+ int counterBefore = DECRYPTION_EXCEPTIONS .get ();
824+ try
825+ {
826+ primary .decrypt (oldEncrypted );
827+ fail ("Expected DecryptionException" );
828+ }
829+ catch (DecryptionException ignored ) {}
830+ assertEquals (counterBefore + 1 , DECRYPTION_EXCEPTIONS .get ());
831+
832+ // Case 2: correct fallback — transparent success, counter unchanged
833+ _migrationFallback = oldAlgorithm ;
834+ try
835+ {
836+ int counterBeforeFallback = DECRYPTION_EXCEPTIONS .get ();
837+ assertEquals (text , primary .decrypt (oldEncrypted ));
838+ assertEquals ("Counter must not increment when fallback succeeds" , counterBeforeFallback , DECRYPTION_EXCEPTIONS .get ());
839+ }
840+ finally
841+ {
842+ _migrationFallback = null ;
843+ }
844+
845+ // Case 3: wrong fallback — both algorithms fail, counter increments
846+ _migrationFallback = new AES ("wrong pass phrase" , 128 , "wrong fallback" );
847+ int counterBeforeWrongFallback = DECRYPTION_EXCEPTIONS .get ();
848+ try
849+ {
850+ primary .decrypt (oldEncrypted );
851+ fail ("Expected DecryptionException" );
852+ }
853+ catch (DecryptionException ignored ) {}
854+ finally
855+ {
856+ _migrationFallback = null ;
857+ }
858+ assertEquals (counterBeforeWrongFallback + 1 , DECRYPTION_EXCEPTIONS .get ());
859+ }
860+
748861 private void test (Algorithm algorithm )
749862 {
750863 test (algorithm , algorithm );
0 commit comments