Skip to content

Commit 0edd669

Browse files
GitHub Issue 1064: Exceptions during encryption migration prevent upgrade to 25.11
1 parent 689a835 commit 0edd669

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

api/src/org/labkey/api/security/Encryption.java

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050

5151
import javax.crypto.BadPaddingException;
5252
import javax.crypto.Cipher;
53+
import javax.crypto.IllegalBlockSizeException;
5354
import javax.crypto.SecretKey;
5455
import javax.crypto.SecretKeyFactory;
5556
import 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);

core/src/org/labkey/core/CoreModule.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,11 @@ public void afterUpdate(ModuleContext moduleContext)
869869
ContainerManager.getHomeContainer();
870870
}
871871
});
872+
873+
// Install a fallback decryption algorithm if AES migration is pending. This prevents concurrent HTTP requests
874+
// from failing to decrypt not-yet-migrated values during the migration window. Called here (afterUpdate) rather
875+
// than in startupAfterSpringConfig so the fallback is active before any long-running upgrade steps run.
876+
Encryption.prepareMigrationFallback();
872877
}
873878

874879
private void bootstrap()

0 commit comments

Comments
 (0)