From 8b9cd3ed32e82d804e481ef8fb98bcbf3475b2a4 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 07:56:47 -0700 Subject: [PATCH 01/10] Add new crypto utilities to replace jasypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces four new classes under io.xh.hoist.security to provide pure-JDK (and Spring-Security-Crypto-backed) alternatives to the jasypt 1.9.3 dependency: - HoistPasswordEncoder: user-facing BCrypt wrapper for consuming apps' User domain classes. matches() transparently verifies both new BCrypt hashes and legacy jasypt-format hashes so existing user records continue to authenticate post-upgrade. isLegacyHash() supports migrate-on-login patterns. - AesTextCipher: AES-256-GCM with PBKDF2WithHmacSHA256-derived keys. Output is Base64 wrapped with a "$hoist-aes1$" marker prefix so values are unambiguously distinguishable from legacy ciphertext. Used internally by AppConfig. - SaltedSha256Digester: per-instance random-salt SHA-256, used internally by AppConfig to produce stable opaque fingerprints of pwd-typed values for the admin UI's config-differ. - LegacyJasyptDecrypter: read-only pure-JDK reproduction of jasypt 1.9.3's default BasicTextEncryptor (PBEWithMD5AndDES, 1000 iterations, 8-byte salt prepended) and BasicPasswordEncryptor (MD5, 1000 iterations, 8-byte salt prepended) algorithms. Enables in-place decode of values written by hoist-core <= v40 without keeping jasypt on the classpath. No callers are wired up to these yet — that arrives in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../security/HoistPasswordEncoder.groovy | 98 ++++++++++ .../security/crypto/AesTextCipher.groovy | 125 +++++++++++++ .../crypto/LegacyJasyptDecrypter.groovy | 169 ++++++++++++++++++ .../crypto/SaltedSha256Digester.groovy | 47 +++++ 4 files changed, 439 insertions(+) create mode 100644 src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy create mode 100644 src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy create mode 100644 src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy create mode 100644 src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy diff --git a/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy b/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy new file mode 100644 index 00000000..31681291 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy @@ -0,0 +1,98 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security + +import groovy.transform.CompileStatic +import io.xh.hoist.security.crypto.LegacyJasyptDecrypter +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder + +/** + * Centralized password hashing for consuming apps' local user accounts. Wraps Spring Security's + * {@code BCryptPasswordEncoder} so app-level User domain classes don't need to take a direct + * dependency on a specific crypto library. + * + *

This replaces the historical pattern of importing jasypt's {@code BasicPasswordEncryptor} + * directly in each app's User domain class. The jasypt dependency was removed in hoist-core + * v41.0 — it is end-of-life (last released 2014) and breaks at runtime on JDK 21+ under Spring + * Boot's launcher classloader due to a Unicode-normalization reflection bug. See the v41 + * upgrade notes for migration details. + * + *

Typical usage in a User domain class

+ *
+ * import io.xh.hoist.security.HoistPasswordEncoder
+ *
+ * class User {
+ *     String username
+ *     String password
+ *
+ *     def beforeInsert() { encodePassword() }
+ *     def beforeUpdate() { if (isDirty('password')) encodePassword() }
+ *
+ *     boolean checkPassword(String plain) {
+ *         HoistPasswordEncoder.matches(plain, password)
+ *     }
+ *
+ *     private void encodePassword() {
+ *         password = HoistPasswordEncoder.encode(password)
+ *     }
+ * }
+ * 
+ * + *

Migrating legacy users

+ * + * {@link #matches(String, String)} transparently verifies passwords stored in either the new + * BCrypt format or the legacy jasypt format (8-byte-salt MD5, the default produced by + * {@code BasicPasswordEncryptor}). This allows existing users to log in successfully against a + * database populated under jasypt. App code is encouraged to detect legacy hashes after a + * successful match and re-save the user with a freshly {@link #encode encoded} password — see + * {@link #isLegacyHash(String)} and the v41 upgrade notes for a complete pattern. + * + *

Once all users have logged in at least once after the upgrade, the legacy path becomes + * dead code and may be removed in a future release. + */ +@CompileStatic +final class HoistPasswordEncoder { + + private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder() + + /** Hash a plaintext password using BCrypt. Returns null for null/empty input. */ + static String encode(String rawPassword) { + return rawPassword ? ENCODER.encode(rawPassword) : null + } + + /** + * Verify a plaintext password against an encoded hash. Supports both the current BCrypt + * format and the legacy jasypt-default format (`BasicPasswordEncryptor`). Returns false + * for null/empty inputs. + */ + static boolean matches(String rawPassword, String encodedPassword) { + if (!rawPassword || !encodedPassword) return false + if (looksLikeBCrypt(encodedPassword)) { + try { + return ENCODER.matches(rawPassword, encodedPassword) + } catch (IllegalArgumentException ignored) { + return false + } + } + return LegacyJasyptDecrypter.matchesLegacyPasswordHash(rawPassword, encodedPassword) + } + + /** + * True if the given encoded hash is in the legacy jasypt format and should be re-encoded + * with {@link #encode} on next opportunity (typically post-login). Callers can use this to + * implement migrate-on-login behavior. + */ + static boolean isLegacyHash(String encodedPassword) { + if (!encodedPassword) return false + return !looksLikeBCrypt(encodedPassword) && + LegacyJasyptDecrypter.looksLikeLegacyPasswordHash(encodedPassword) + } + + private static boolean looksLikeBCrypt(String value) { + return value.length() == 60 && value.startsWith('$2') + } +} diff --git a/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy new file mode 100644 index 00000000..99199384 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy @@ -0,0 +1,125 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import groovy.transform.CompileStatic + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import java.nio.charset.StandardCharsets +import java.security.SecureRandom + +/** + * Symmetric AES-256-GCM text encryption with a PBKDF2-derived key, used internally by + * {@link io.xh.hoist.config.AppConfig} to protect `pwd`-typed config values at rest. + * + *

Replaces the historical use of jasypt's {@code BasicTextEncryptor}. Values produced by this + * cipher carry the {@link #FORMAT_PREFIX} marker so callers can distinguish them from values + * produced by the legacy encryptor (see + * {@link io.xh.hoist.security.crypto.LegacyJasyptDecrypter}). + * + *

This class is internal to hoist-core. App code should not depend on its output format — + * see {@link io.xh.hoist.security.HoistPasswordEncoder} for the user-facing password hashing API. + */ +@CompileStatic +final class AesTextCipher { + + /** Marker prefix on output ciphertext — identifies hoist-core v1 AES-GCM format. */ + static final String FORMAT_PREFIX = '$hoist-aes1$' + + private static final String KEY_ALGORITHM = 'PBKDF2WithHmacSHA256' + private static final String CIPHER_ALGORITHM = 'AES/GCM/NoPadding' + private static final int KEY_LENGTH_BITS = 256 + private static final int PBKDF2_ITERATIONS = 65_536 + private static final int SALT_LENGTH_BYTES = 16 + private static final int IV_LENGTH_BYTES = 12 + private static final int GCM_TAG_LENGTH_BITS = 128 + + private final char[] password + private final SecureRandom random = new SecureRandom() + + AesTextCipher(String password) { + if (!password) throw new IllegalArgumentException('password must be non-empty') + this.password = password.toCharArray() + } + + /** + * Encrypt the given plaintext, returning a single self-contained Base64-encoded string + * prefixed with {@link #FORMAT_PREFIX}. Each call uses a freshly random salt and IV. + */ + String encrypt(String plaintext) { + if (plaintext == null) throw new IllegalArgumentException('plaintext must not be null') + + byte[] salt = new byte[SALT_LENGTH_BYTES] + random.nextBytes(salt) + byte[] iv = new byte[IV_LENGTH_BYTES] + random.nextBytes(iv) + + SecretKey key = deriveKey(salt) + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)) + + // Layout: [salt(16)][iv(12)][ciphertext+gcm_tag(variable)] + byte[] packed = new byte[salt.length + iv.length + ct.length] + System.arraycopy(salt, 0, packed, 0, salt.length) + System.arraycopy(iv, 0, packed, salt.length, iv.length) + System.arraycopy(ct, 0, packed, salt.length + iv.length, ct.length) + + return FORMAT_PREFIX + Base64.encoder.encodeToString(packed) + } + + /** + * Decrypt a value previously produced by {@link #encrypt}. Throws + * {@link IllegalArgumentException} if the input does not carry the expected format prefix. + */ + String decrypt(String ciphertext) { + if (ciphertext == null) throw new IllegalArgumentException('ciphertext must not be null') + if (!isHoistFormat(ciphertext)) { + throw new IllegalArgumentException( + "Ciphertext does not carry the expected '$FORMAT_PREFIX' prefix" + ) + } + + byte[] packed = Base64.decoder.decode(ciphertext.substring(FORMAT_PREFIX.length())) + if (packed.length < SALT_LENGTH_BYTES + IV_LENGTH_BYTES + 1) { + throw new IllegalArgumentException('Ciphertext payload is too short') + } + + byte[] salt = new byte[SALT_LENGTH_BYTES] + byte[] iv = new byte[IV_LENGTH_BYTES] + byte[] ct = new byte[packed.length - SALT_LENGTH_BYTES - IV_LENGTH_BYTES] + System.arraycopy(packed, 0, salt, 0, SALT_LENGTH_BYTES) + System.arraycopy(packed, SALT_LENGTH_BYTES, iv, 0, IV_LENGTH_BYTES) + System.arraycopy(packed, SALT_LENGTH_BYTES + IV_LENGTH_BYTES, ct, 0, ct.length) + + SecretKey key = deriveKey(salt) + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + byte[] pt = cipher.doFinal(ct) + return new String(pt, StandardCharsets.UTF_8) + } + + /** True if the given value carries this cipher's format marker. */ + static boolean isHoistFormat(String value) { + return value != null && value.startsWith(FORMAT_PREFIX) + } + + private SecretKey deriveKey(byte[] salt) { + PBEKeySpec spec = new PBEKeySpec(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS) + try { + byte[] keyBytes = SecretKeyFactory.getInstance(KEY_ALGORITHM).generateSecret(spec).encoded + return new SecretKeySpec(keyBytes, 'AES') + } finally { + spec.clearPassword() + } + } +} diff --git a/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy new file mode 100644 index 00000000..6928ee39 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy @@ -0,0 +1,169 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import groovy.transform.CompileStatic + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.PBEParameterSpec +import java.nio.CharBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.text.Normalizer + +/** + * Read-only, pure-JDK reproduction of jasypt 1.9.3's default symmetric text encryption + * ({@code BasicTextEncryptor}) and one-way password hash ({@code BasicPasswordEncryptor}) + * algorithms. Used to verify and migrate legacy values produced by hoist-core <= v40 and apps + * whose User domains historically depended on jasypt directly. + * + *

This class exists strictly to support a one-release transition window: new ciphertexts and + * hashes are written by {@link AesTextCipher} and + * {@link io.xh.hoist.security.HoistPasswordEncoder}, while legacy values continue to be readable + * via this helper until they have been organically migrated (re-saving a `pwd` config, logging + * in as a local user, etc.). + * + *

Compatibility notes

+ * + * + *

Both algorithms apply NFC Unicode normalization to inputs before processing, mirroring + * jasypt. (We use {@link java.text.Normalizer} directly here, which on its own is not the + * codepath that breaks under Spring Boot's launcher classloader — jasypt's failure is in its + * reflective wrapper around the same JDK API.) + */ +@CompileStatic +final class LegacyJasyptDecrypter { + + private static final String PBE_ALGORITHM = 'PBEWithMD5AndDES' + private static final int PBE_ITERATIONS = 1000 + private static final int PBE_SALT_BYTES = 8 + + private static final String DIGEST_ALGORITHM = 'MD5' + private static final int DIGEST_ITERATIONS = 1000 + private static final int DIGEST_SALT_BYTES = 8 + private static final int DIGEST_LENGTH_BYTES = 16 // MD5 output size + + private final char[] password + + LegacyJasyptDecrypter(String password) { + if (!password) throw new IllegalArgumentException('password must be non-empty') + this.password = password.toCharArray() + } + + /** + * Decrypt a value previously produced by jasypt's {@code BasicTextEncryptor.encrypt(plain)} + * with the same password. The input is the raw Base64 string jasypt wrote into the + * database — no marker prefix. + */ + String decrypt(String encodedCiphertext) { + if (encodedCiphertext == null) throw new IllegalArgumentException('ciphertext must not be null') + + byte[] packed = Base64.decoder.decode(encodedCiphertext) + if (packed.length <= PBE_SALT_BYTES) { + throw new IllegalArgumentException('Legacy jasypt ciphertext is too short') + } + byte[] salt = new byte[PBE_SALT_BYTES] + byte[] ct = new byte[packed.length - PBE_SALT_BYTES] + System.arraycopy(packed, 0, salt, 0, PBE_SALT_BYTES) + System.arraycopy(packed, PBE_SALT_BYTES, ct, 0, ct.length) + + char[] normalizedPwd = nfcNormalize(password) + try { + PBEKeySpec keySpec = new PBEKeySpec(normalizedPwd) + try { + SecretKey key = SecretKeyFactory.getInstance(PBE_ALGORITHM).generateSecret(keySpec) + Cipher cipher = Cipher.getInstance(PBE_ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, key, new PBEParameterSpec(salt, PBE_ITERATIONS)) + byte[] plain = cipher.doFinal(ct) + return new String(plain, StandardCharsets.UTF_8) + } finally { + keySpec.clearPassword() + } + } finally { + java.util.Arrays.fill(normalizedPwd, '\0' as char) + } + } + + //---------------------------------------------------------------- + // Legacy password (one-way digest) verification + //---------------------------------------------------------------- + + /** + * True if the given plaintext password, hashed with jasypt's {@code BasicPasswordEncryptor} + * algorithm using the salt extracted from {@code legacyEncoded}, matches the encoded digest. + */ + static boolean matchesLegacyPasswordHash(String plain, String legacyEncoded) { + if (plain == null || legacyEncoded == null) return false + byte[] packed + try { + packed = Base64.decoder.decode(legacyEncoded) + } catch (IllegalArgumentException ignored) { + return false + } + if (packed.length != DIGEST_SALT_BYTES + DIGEST_LENGTH_BYTES) return false + + byte[] salt = new byte[DIGEST_SALT_BYTES] + byte[] expected = new byte[DIGEST_LENGTH_BYTES] + System.arraycopy(packed, 0, salt, 0, DIGEST_SALT_BYTES) + System.arraycopy(packed, DIGEST_SALT_BYTES, expected, 0, DIGEST_LENGTH_BYTES) + + byte[] actual = computeLegacyDigest(plain, salt) + return MessageDigest.isEqual(expected, actual) + } + + /** + * Heuristic: the given encoded string has the shape of a jasypt + * {@code BasicPasswordEncryptor} digest (Base64-encoded 24 raw bytes = 8 salt + 16 MD5). + * Used to decide whether to attempt legacy verification or fall through. + */ + static boolean looksLikeLegacyPasswordHash(String encoded) { + if (encoded == null || encoded.isEmpty()) return false + byte[] decoded + try { + decoded = Base64.decoder.decode(encoded) + } catch (IllegalArgumentException ignored) { + return false + } + return decoded.length == DIGEST_SALT_BYTES + DIGEST_LENGTH_BYTES + } + + private static byte[] computeLegacyDigest(String plain, byte[] salt) { + char[] normalized = nfcNormalize(plain.toCharArray()) + try { + byte[] msg = new String(normalized).getBytes(StandardCharsets.UTF_8) + byte[] combined = new byte[salt.length + msg.length] + System.arraycopy(salt, 0, combined, 0, salt.length) + System.arraycopy(msg, 0, combined, salt.length, msg.length) + + MessageDigest md = MessageDigest.getInstance(DIGEST_ALGORITHM) + byte[] digest = md.digest(combined) + // jasypt re-digests its prior output (without re-prepending salt) for the remaining + // iterations - see StandardByteDigester.digest(). + for (int i = 1; i < DIGEST_ITERATIONS; i++) { + md.reset() + digest = md.digest(digest) + } + return digest + } finally { + java.util.Arrays.fill(normalized, '\0' as char) + } + } + + private static char[] nfcNormalize(char[] input) { + String normalized = Normalizer.normalize(CharBuffer.wrap(input), Normalizer.Form.NFC) + return normalized.toCharArray() + } +} diff --git a/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy b/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy new file mode 100644 index 00000000..f7ca5b44 --- /dev/null +++ b/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy @@ -0,0 +1,47 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import groovy.transform.CompileStatic + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * One-way SHA-256 digester used internally by {@link io.xh.hoist.config.AppConfig} to render a + * comparable, non-reversible fingerprint of `pwd`-typed config values in the admin UI's + * config-differ display. + * + *

This is not a password storage primitive — it is used purely to produce a + * stable, opaque token so the admin UI can detect whether two `pwd` values are identical + * without revealing the underlying plaintext. For user-password hashing, see + * {@link io.xh.hoist.security.HoistPasswordEncoder}. + * + *

A random salt is generated once per instance and then reused for every call, so two calls + * with the same input on the same digester instance produce identical output (suitable for + * within-process diffing). + */ +@CompileStatic +final class SaltedSha256Digester { + + private final byte[] salt + + SaltedSha256Digester() { + this.salt = new byte[16] + new SecureRandom().nextBytes(salt) + } + + /** Returns a Base64-encoded SHA-256 digest of the given input mixed with this instance's salt. */ + String digest(String input) { + if (input == null) return null + MessageDigest md = MessageDigest.getInstance('SHA-256') + md.update(salt) + md.update(input.getBytes(StandardCharsets.UTF_8)) + return Base64.encoder.encodeToString(md.digest()) + } +} From 469b5a13fb8afa692bb5ebdd71c63414ba611a1d Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 08:00:00 -0700 Subject: [PATCH 02/10] Remove jasypt: rewire AppConfig to new crypto utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the org.jasypt:jasypt:1.9.3 dependency entirely. Jasypt has been end-of-life since 2014 and breaks at runtime on JDK 21+ under Spring Boot's launcher classloader: BasicTextEncryptor.encrypt and BasicPasswordEncryptor.encryptPassword both throw "Could not perform a valid UNICODE normalization" from a reflective call inside jasypt's Normalizer wrapper. Standard --add-opens flags do not resolve this — the failure is in jasypt's reflective code path, not JDK module accessibility — so the library is effectively unusable on modern JDKs. build.gradle: - Remove "api org.jasypt:jasypt:1.9.3" - Add "api org.springframework.security:spring-security-crypto" (version managed by Spring Boot BOM; only the crypto module — no filters/controllers) AppConfig.groovy: - Static encryptor now an AesTextCipher (AES-256-GCM, PBKDF2-derived key) - Static digestEncryptor now a SaltedSha256Digester (per-process random salt, stable within instance) - New static legacyDecrypter (LegacyJasyptDecrypter) handles read-side fallback for values written by hoist-core <= v40 - decryptPassword now dispatches by the AesTextCipher.FORMAT_PREFIX marker — new ciphertext routes to the AES cipher, legacy ciphertext routes to the legacy decrypter. Re-saving any pwd config promotes it to the new format; no DB migration required. - Hardcoded encryption password preserved verbatim (it is in the open-source source already, so not a secret) — it now seeds both the new PBKDF2 derivation and the legacy decrypter, allowing the marker prefix to safely distinguish formats. Sourcing this key from instance config remains a future enhancement. Co-Authored-By: Claude Opus 4.7 (1M context) --- build.gradle | 7 ++- .../io/xh/hoist/config/AppConfig.groovy | 45 +++++++++++-------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/build.gradle b/build.gradle index ac87e810..e3b2647a 100644 --- a/build.gradle +++ b/build.gradle @@ -90,8 +90,13 @@ dependencies { api "org.apache.poi:poi-ooxml-full:5.5.1" api "org.apache.poi:poi-ooxml:5.5.1" api "org.apache.poi:poi:5.5.1" - api "org.jasypt:jasypt:1.9.3" api "org.owasp.encoder:encoder:1.3.1" + // Spring Security Crypto provides BCryptPasswordEncoder for io.xh.hoist.security.HoistPasswordEncoder + // and is not transitively included by any of the Spring Boot starters hoist-core consumes. + // Only the crypto module is pulled in (no filters, controllers, etc.). Version is managed by + // the Spring Boot BOM. Replaces the historical jasypt 1.9.3 dependency which was end-of-life + // and broke at runtime under JDK 21+ Spring Boot launcher classloader. + api "org.springframework.security:spring-security-crypto" api "org.springframework:spring-websocket" api "io.micrometer:micrometer-registry-prometheus" api "io.micrometer:micrometer-registry-otlp" diff --git a/grails-app/domain/io/xh/hoist/config/AppConfig.groovy b/grails-app/domain/io/xh/hoist/config/AppConfig.groovy index f231d5a6..22ab9e53 100644 --- a/grails-app/domain/io/xh/hoist/config/AppConfig.groovy +++ b/grails-app/domain/io/xh/hoist/config/AppConfig.groovy @@ -10,18 +10,28 @@ package io.xh.hoist.config import io.xh.hoist.json.JSONFormat import io.xh.hoist.json.JSONParser import io.xh.hoist.log.LogSupport +import io.xh.hoist.security.crypto.AesTextCipher +import io.xh.hoist.security.crypto.LegacyJasyptDecrypter +import io.xh.hoist.security.crypto.SaltedSha256Digester import io.xh.hoist.util.InstanceConfigUtils import io.xh.hoist.util.Utils -import org.jasypt.util.password.ConfigurablePasswordEncryptor -import org.jasypt.util.text.BasicTextEncryptor -import org.jasypt.util.text.TextEncryptor import static grails.async.Promises.task class AppConfig implements JSONFormat, LogSupport { - static private final TextEncryptor encryptor = createEncryptor() - static private final ConfigurablePasswordEncryptor digestEncryptor = createDigestEncryptor() + // Hard-coded encryption password — preserved across the jasypt removal in v41 so that + // pwd-typed config values written by hoist-core <= v40 (which used jasypt's + // BasicTextEncryptor under this same password) remain decryptable via the legacy fallback. + // The same string seeds the new AES-GCM cipher via PBKDF2, but new ciphertext is + // distinguishable from legacy by the AesTextCipher.FORMAT_PREFIX marker, so the two + // never collide. (See the v41 upgrade notes for context — sourcing this key from + // instance config is a future enhancement.) + private static final String CONFIG_ENCRYPTION_PASSWORD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + + static private final AesTextCipher encryptor = new AesTextCipher(CONFIG_ENCRYPTION_PASSWORD) + static private final LegacyJasyptDecrypter legacyDecrypter = new LegacyJasyptDecrypter(CONFIG_ENCRYPTION_PASSWORD) + static private final SaltedSha256Digester digestEncryptor = new SaltedSha256Digester() static List TYPES = ['string', 'int', 'long', 'double', 'bool', 'json', 'pwd'] @@ -104,18 +114,6 @@ class AppConfig implements JSONFormat, LogSupport { } } - private static TextEncryptor createEncryptor() { - def ret = new BasicTextEncryptor() - ret.setPassword('dsd899s_*)jsk9dsl2fd223hpdj32))I@333') - return ret - } - - private static ConfigurablePasswordEncryptor createDigestEncryptor() { - def ret = new ConfigurablePasswordEncryptor() - ret.setPlainDigest(true) - ret - } - private Object overrideValue(Map opts = [:]) { String overrideValue = InstanceConfigUtils.getInstanceConfig(name) if (overrideValue == null) return null @@ -150,11 +148,20 @@ class AppConfig implements JSONFormat, LogSupport { // Allow pwd values to be compared in the admin config differ, without exposing the actual value. private static String digestPassword(String value, boolean isEncrypted) { - digestEncryptor.encryptPassword(isEncrypted ? decryptPassword(value) : value) + digestEncryptor.digest(isEncrypted ? decryptPassword(value) : value) } + /** + * Decrypt a stored pwd-type value. Transparently handles both the current v41+ AES-GCM + * format and legacy values written by hoist-core <= v40 (jasypt's PBEWithMD5AndDES). + * Legacy values are not rewritten in-place by this read path — they upgrade to the new + * format the next time the AppConfig row is saved (e.g. via the admin UI). + */ private static String decryptPassword(String value) { - encryptor.decrypt(value) + if (AesTextCipher.isHoistFormat(value)) { + return encryptor.decrypt(value) + } + return legacyDecrypter.decrypt(value) } Map formatForJSON() { From f26d8517d686c7c21d69397be3495c88a1ff2f19 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 08:28:21 -0700 Subject: [PATCH 03/10] Add Spock specs for new crypto utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First unit tests in hoist-core. Adds src/test/groovy/ with five Spock specs covering the v41 crypto rework: - AesTextCipherSpec: round-trip across unicode/large/empty inputs, fresh-output-per-call, format marker detection, null-input rejection, wrong-password failure, cross-instance compatibility. - SaltedSha256DigesterSpec: stable-within-instance, varies across instances, base64 output shape. - LegacyJasyptDecrypterSpec: decrypts four BasicTextEncryptor fixtures and matches six BasicPasswordEncryptor fixtures — all captured out-of-band from jasypt 1.9.3 itself, anchoring the migration shim's correctness against real legacy data shapes. - AppConfigEncryptionRoutingSpec: exercises the same marker-prefix dispatch logic AppConfig uses, confirming a mixed-format DB reads correctly regardless of write era. - HoistPasswordEncoderSpec: BCrypt encode/match round trip, unique-hash-per-call, transparent legacy verification, isLegacyHash detection, malformed-input handling. build.gradle gains testImplementation "org.spockframework:spock-core:2.3-groovy-4.0" plus tasks.named('test') { useJUnitPlatform() }. All 45 tests pass under ./gradlew test. Co-Authored-By: Claude Opus 4.7 (1M context) --- build.gradle | 8 ++ .../security/HoistPasswordEncoderSpec.groovy | 81 +++++++++++++++ .../security/crypto/AesTextCipherSpec.groovy | 99 +++++++++++++++++++ .../AppConfigEncryptionRoutingSpec.groovy | 53 ++++++++++ .../crypto/LegacyJasyptDecrypterSpec.groovy | 88 +++++++++++++++++ .../crypto/SaltedSha256DigesterSpec.groovy | 53 ++++++++++ 6 files changed, 382 insertions(+) create mode 100644 src/test/groovy/io/xh/hoist/security/HoistPasswordEncoderSpec.groovy create mode 100644 src/test/groovy/io/xh/hoist/security/crypto/AesTextCipherSpec.groovy create mode 100644 src/test/groovy/io/xh/hoist/security/crypto/AppConfigEncryptionRoutingSpec.groovy create mode 100644 src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy create mode 100644 src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy diff --git a/build.gradle b/build.gradle index e3b2647a..41d50688 100644 --- a/build.gradle +++ b/build.gradle @@ -110,6 +110,14 @@ dependencies { // Workaround needed by micrometer-registry-otlp at compile time api "io.opentelemetry.proto:opentelemetry-proto:1.10.0-alpha" + //-------------------- + // Test + //-------------------- + testImplementation "org.spockframework:spock-core:2.3-groovy-4.0" +} + +tasks.named('test') { + useJUnitPlatform() } tasks.withType(GroovyCompile) { diff --git a/src/test/groovy/io/xh/hoist/security/HoistPasswordEncoderSpec.groovy b/src/test/groovy/io/xh/hoist/security/HoistPasswordEncoderSpec.groovy new file mode 100644 index 00000000..43be625a --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/HoistPasswordEncoderSpec.groovy @@ -0,0 +1,81 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security + +import spock.lang.Specification + +class HoistPasswordEncoderSpec extends Specification { + + def 'encode + matches round trip works for arbitrary passwords'() { + when: + def encoded = HoistPasswordEncoder.encode(plain) + + then: + encoded + encoded != plain + encoded.startsWith('$2') // BCrypt prefix + HoistPasswordEncoder.matches(plain, encoded) + + and: 'wrong password fails' + !HoistPasswordEncoder.matches('wrong-' + plain, encoded) + !HoistPasswordEncoder.matches('', encoded) + !HoistPasswordEncoder.matches(null, encoded) + + where: + plain << ['password', 'a', 'café é résumé', '🚀 emoji 🎉', + 'a-much-longer-passphrase-with-various-characters-!@#$%^&*()'] + } + + def 'encode produces a different hash each call (BCrypt random salt)'() { + when: + def a = HoistPasswordEncoder.encode('secret') + def b = HoistPasswordEncoder.encode('secret') + + then: + a != b + HoistPasswordEncoder.matches('secret', a) + HoistPasswordEncoder.matches('secret', b) + } + + def 'encode returns null for null/empty input'() { + expect: + HoistPasswordEncoder.encode(null) == null + HoistPasswordEncoder.encode('') == null + } + + def 'matches transparently verifies legacy jasypt hashes'() { + // Fixtures captured from jasypt 1.9.3's BasicPasswordEncryptor.encryptPassword(plain) + expect: + HoistPasswordEncoder.matches(plain, legacyEncoded) + + and: 'and exposes them as needing migration' + HoistPasswordEncoder.isLegacyHash(legacyEncoded) + + where: + plain | legacyEncoded + 'secret' | 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv' + 'password' | 'FJ4Cw1xTfDMVAHFv1bmv7OZmSsKnieZD' + 'playwright-test' | '3hrIc7ebBL/NxnkkVk0OWUAMyxRVld3U' + } + + def 'fresh BCrypt hashes are not flagged as legacy'() { + given: + def encoded = HoistPasswordEncoder.encode('whatever') + + expect: + !HoistPasswordEncoder.isLegacyHash(encoded) + } + + def 'malformed encoded strings are rejected gracefully'() { + expect: + !HoistPasswordEncoder.matches('any', 'not-a-valid-hash-at-all') + !HoistPasswordEncoder.matches('any', '') + !HoistPasswordEncoder.matches('any', null) + !HoistPasswordEncoder.isLegacyHash(null) + !HoistPasswordEncoder.isLegacyHash('') + } +} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/AesTextCipherSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/AesTextCipherSpec.groovy new file mode 100644 index 00000000..ed75a78b --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/crypto/AesTextCipherSpec.groovy @@ -0,0 +1,99 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import spock.lang.Specification + +class AesTextCipherSpec extends Specification { + + private static final String PWD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + + def 'round-trips arbitrary plaintext including unicode'() { + given: + def cipher = new AesTextCipher(PWD) + + expect: + cipher.decrypt(cipher.encrypt(input)) == input + + where: + input << ['', 'hello world', 'a-much-longer-value-that-should-still-encode-fine!', + 'café é résumé', '🚀 emoji 🎉', 'ABCD' * 200] + } + + def 'encrypt produces fresh output on every call (random IV/salt)'() { + given: + def cipher = new AesTextCipher(PWD) + + when: + def a = cipher.encrypt('repeat') + def b = cipher.encrypt('repeat') + + then: + a != b + cipher.decrypt(a) == 'repeat' + cipher.decrypt(b) == 'repeat' + } + + def 'isHoistFormat correctly tags new-format ciphertext'() { + given: + def cipher = new AesTextCipher(PWD) + def newCt = cipher.encrypt('value') + + expect: + AesTextCipher.isHoistFormat(newCt) + newCt.startsWith(AesTextCipher.FORMAT_PREFIX) + !AesTextCipher.isHoistFormat(null) + !AesTextCipher.isHoistFormat('') + !AesTextCipher.isHoistFormat('sYvsDA6GhTFy97SLuc8I3La99s6fkC8S') // legacy fixture + } + + def 'decrypting non-hoist-format input throws'() { + given: + def cipher = new AesTextCipher(PWD) + + when: + cipher.decrypt('not-a-hoist-aes-string') + + then: + thrown(IllegalArgumentException) + } + + def 'cross-instance decrypt works (same password)'() { + given: + def encrypting = new AesTextCipher(PWD) + def decrypting = new AesTextCipher(PWD) + + expect: + decrypting.decrypt(encrypting.encrypt('shared secret')) == 'shared secret' + } + + def 'wrong password fails to decrypt'() { + given: + def encrypting = new AesTextCipher(PWD) + def wrong = new AesTextCipher('something-different') + def ct = encrypting.encrypt('value') + + when: + wrong.decrypt(ct) + + then: + // AES-GCM authentication failure surfaces as a javax.crypto.AEADBadTagException + // (subclass of BadPaddingException) — captured here as any Throwable. + thrown(Throwable) + } + + def 'null inputs are rejected up front'() { + given: + def cipher = new AesTextCipher(PWD) + + when: cipher.encrypt(null) + then: thrown(IllegalArgumentException) + + when: cipher.decrypt(null) + then: thrown(IllegalArgumentException) + } +} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/AppConfigEncryptionRoutingSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/AppConfigEncryptionRoutingSpec.groovy new file mode 100644 index 00000000..be6dbb90 --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/crypto/AppConfigEncryptionRoutingSpec.groovy @@ -0,0 +1,53 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import spock.lang.Specification + +/** + * Verifies the dispatch logic used by {@code AppConfig.decryptPassword} — new ciphertext routes + * to {@link AesTextCipher}, legacy ciphertext (no hoist marker) routes to + * {@link LegacyJasyptDecrypter}. This mirrors how a mixed-format database is read during an + * in-place upgrade from hoist-core <= v40. + */ +class AppConfigEncryptionRoutingSpec extends Specification { + + private static final String APPCONFIG_PWD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + + private final AesTextCipher cipher = new AesTextCipher(APPCONFIG_PWD) + private final LegacyJasyptDecrypter legacy = new LegacyJasyptDecrypter(APPCONFIG_PWD) + + private String decryptPassword(String value) { + AesTextCipher.isHoistFormat(value) ? cipher.decrypt(value) : legacy.decrypt(value) + } + + def 'new-format ciphertext is decrypted via the AES cipher'() { + given: + def encoded = cipher.encrypt('my-fresh-secret') + + expect: + encoded.startsWith(AesTextCipher.FORMAT_PREFIX) + decryptPassword(encoded) == 'my-fresh-secret' + } + + def 'legacy jasypt ciphertext falls back to the legacy decrypter'() { + // Captured out-of-band from jasypt 1.9.3's BasicTextEncryptor using APPCONFIG_PWD + expect: + decryptPassword('sYvsDA6GhTFy97SLuc8I3La99s6fkC8S') == 'hello world' + decryptPassword('0WLuQW9MxI0qPNOqTwVT60ZPGDl/QU0Y') == 'roundtrip-test' + } + + def 'mixed-format reads return the correct plaintext regardless of write era'() { + given: + def newCt = cipher.encrypt('newly-written-secret') + def legacyCt = 'sYvsDA6GhTFy97SLuc8I3La99s6fkC8S' // jasypt-written 'hello world' + + expect: + decryptPassword(newCt) == 'newly-written-secret' + decryptPassword(legacyCt) == 'hello world' + } +} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy new file mode 100644 index 00000000..366d1697 --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy @@ -0,0 +1,88 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import spock.lang.Specification + +/** + * Verifies {@link LegacyJasyptDecrypter} correctly reproduces the decode side of jasypt 1.9.3's + * {@code BasicTextEncryptor} and {@code BasicPasswordEncryptor} default algorithms. + * + *

Test vectors below were captured out-of-band from jasypt 1.9.3 itself (the same + * implementation that wrote them into existing production databases). They are stable encoded + * outputs — each random-salt-prefixed Base64 string decrypts deterministically against the + * given password / verifies against the given plaintext. + */ +class LegacyJasyptDecrypterSpec extends Specification { + + // Same password historically hard-coded in AppConfig.groovy for `pwd` value encryption. + private static final String APPCONFIG_PWD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + + def 'decrypts known jasypt BasicTextEncryptor fixtures'() { + given: + def decrypter = new LegacyJasyptDecrypter(APPCONFIG_PWD) + + expect: + decrypter.decrypt(ciphertext) == plaintext + + where: + ciphertext | plaintext + 'sYvsDA6GhTFy97SLuc8I3La99s6fkC8S' | 'hello world' + '7j0sw7gOeG8Lr/bZHxARBw==' | '' + 'HNy2JIeH4NnJdyzFJ9hS+lzfPBtbF43fyWrHL/l2YI0=' | 'café é résumé' + '0WLuQW9MxI0qPNOqTwVT60ZPGDl/QU0Y' | 'roundtrip-test' + } + + def 'rejects too-short ciphertext'() { + given: + def decrypter = new LegacyJasyptDecrypter(APPCONFIG_PWD) + + when: + decrypter.decrypt(Base64.encoder.encodeToString(new byte[4])) + + then: + thrown(IllegalArgumentException) + } + + def 'matches known jasypt BasicPasswordEncryptor digest fixtures'() { + // BasicPasswordEncryptor produces a per-call random salt -- each line below is a different + // encoded form of the SAME plaintext, validating that salt is correctly extracted. + expect: + LegacyJasyptDecrypter.matchesLegacyPasswordHash(plain, encoded) + + where: + plain | encoded + 'secret' | 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv' + 'secret' | '4ZciBiDn8vFKS7KfWKDtrPI2zh65npVq' + 'secret' | '3vCC8qlzR40smcGfXLA795dxcnyRNmoS' + 'secret' | 'mWwYfNJOQyvOVIFEVFJkgro8OyZjvNM4' + 'password' | 'FJ4Cw1xTfDMVAHFv1bmv7OZmSsKnieZD' + 'playwright-test' | '3hrIc7ebBL/NxnkkVk0OWUAMyxRVld3U' + } + + def 'rejects wrong plaintext for legacy digest'() { + expect: + !LegacyJasyptDecrypter.matchesLegacyPasswordHash('wrong', 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv') + !LegacyJasyptDecrypter.matchesLegacyPasswordHash('SECRET', 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv') + !LegacyJasyptDecrypter.matchesLegacyPasswordHash('', 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv') + !LegacyJasyptDecrypter.matchesLegacyPasswordHash(null, 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv') + !LegacyJasyptDecrypter.matchesLegacyPasswordHash('secret', null) + } + + def 'looksLikeLegacyPasswordHash recognises 24-byte base64 strings'() { + expect: + LegacyJasyptDecrypter.looksLikeLegacyPasswordHash('Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv') + LegacyJasyptDecrypter.looksLikeLegacyPasswordHash('3hrIc7ebBL/NxnkkVk0OWUAMyxRVld3U') + + and: 'rejects non-base64, wrong length, null, BCrypt-shaped' + !LegacyJasyptDecrypter.looksLikeLegacyPasswordHash(null) + !LegacyJasyptDecrypter.looksLikeLegacyPasswordHash('') + !LegacyJasyptDecrypter.looksLikeLegacyPasswordHash('not-base64-!!!') + !LegacyJasyptDecrypter.looksLikeLegacyPasswordHash('$2a$10$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123') + !LegacyJasyptDecrypter.looksLikeLegacyPasswordHash(Base64.encoder.encodeToString(new byte[16])) + } +} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy new file mode 100644 index 00000000..94d2e0b0 --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy @@ -0,0 +1,53 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import spock.lang.Specification + +class SaltedSha256DigesterSpec extends Specification { + + def 'same instance produces stable output for same input'() { + given: + def digester = new SaltedSha256Digester() + + expect: + digester.digest('hello') == digester.digest('hello') + digester.digest('') == digester.digest('') + } + + def 'different inputs hash differently within an instance'() { + given: + def digester = new SaltedSha256Digester() + + expect: + digester.digest('hello') != digester.digest('world') + } + + def 'different instances produce different output for same input (random salt)'() { + given: + def a = new SaltedSha256Digester() + def b = new SaltedSha256Digester() + + expect: + a.digest('hello') != b.digest('hello') + } + + def 'null input returns null'() { + expect: + new SaltedSha256Digester().digest(null) == null + } + + def 'digest output is non-empty base64'() { + when: + def out = new SaltedSha256Digester().digest('hello') + + then: + out + out.length() > 0 + Base64.decoder.decode(out).length == 32 // SHA-256 + } +} From 97739b641ce4c2ef0175d3507f026ef24afb76c0 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 08:28:32 -0700 Subject: [PATCH 04/10] Document jasypt removal: CHANGELOG, v41 upgrade notes, CLAUDE.md CHANGELOG (41.0-SNAPSHOT): breaking-change section calling out the jasypt removal with pointer to the upgrade notes, plus technical entries detailing the AesTextCipher / SaltedSha256Digester / HoistPasswordEncoder / LegacyJasyptDecrypter additions, the spring-security-crypto dependency, and the first src/test/groovy/ tree in the repo. docs/upgrade-notes/v41-upgrade-notes.md (new): step-by-step migration with explicit before/after for consuming apps' User domain class swap from BasicPasswordEncryptor to HoistPasswordEncoder, optional migrate-on-login pattern using isLegacyHash, and a "no DB migration required" section covering the AppConfig pwd-value handling. CLAUDE.md: refresh the AppConfig description and key-dependencies list to reflect the move off jasypt. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 44 +++++- CLAUDE.md | 7 +- docs/upgrade-notes/v41-upgrade-notes.md | 173 ++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 docs/upgrade-notes/v41-upgrade-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b667291..887c8586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,21 +2,55 @@ ## 41.0-SNAPSHOT - unreleased -### ⚙️ Technical - +### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - only affects apps with local user passwords) + +See [`docs/upgrade-notes/v41-upgrade-notes.md`](docs/upgrade-notes/v41-upgrade-notes.md) for +detailed migration instructions. + +* Removed the `org.jasypt:jasypt:1.9.3` dependency. Jasypt was end-of-life (last released 2014) and + broke at runtime on JDK 21+ under Spring Boot's launcher classloader due to a + Unicode-normalization reflection bug — observable as + `EncryptionInitializationException: Could not perform a valid UNICODE normalization` thrown from + `BasicPasswordEncryptor.encryptPassword` or `BasicTextEncryptor.encrypt` during `bootRun`. Apps + that imported `org.jasypt.util.password.BasicPasswordEncryptor` in their User domain classes ( + transitively via hoist-core's `api` scope) must switch to the new + `io.xh.hoist.security.HoistPasswordEncoder` — see the upgrade notes for a drop-in snippet. The + encoder's `matches()` method transparently verifies both new BCrypt hashes and legacy + jasypt-format hashes, so existing user passwords continue to work without a forced reset; + `isLegacyHash()` lets apps re-encode on next successful login if desired. + +### ⚙️ Technical + +* Replaced jasypt's internal use in `AppConfig` with pure-JDK AES-256-GCM (key derived via + PBKDF2WithHmacSHA256) for symmetric `pwd`-value encryption and a salted SHA-256 digester for the + admin UI's config-diff fingerprint. New ciphertexts carry the `$hoist-aes1$` marker prefix so they + are unambiguously distinguishable from legacy values; a one-release `LegacyJasyptDecrypter` shim + handles in-place read of pre-v41 ciphertexts written by the previous `BasicTextEncryptor` + codepath, with no DB migration required. +* Added `io.xh.hoist.security.HoistPasswordEncoder`, a thin wrapper around Spring Security's + `BCryptPasswordEncoder` for app-level local-user password hashing. Replaces the historical + convention of importing jasypt directly in each app's User domain class. +* Added `org.springframework.security:spring-security-crypto` (crypto module only — no controllers, + filters, or other Spring Security surface area pulled in). Version managed by the Spring Boot BOM. +* Added a `src/test/groovy/` tree with Spock specs covering the new crypto utilities — first unit + tests landed in hoist-core. Test fixtures include known-good ciphertexts and digests generated + out-of-band by jasypt 1.9.3 to verify legacy compatibility. * Reworked identity resolution onto an explicit per-thread `HoistIdentity` cache, installed at every framework thread-entry point (`HoistFilter`, `HoistWebSocketHandler`, async `task` workers via a new `HoistPromiseFactory`, and `ClusterTask`). Identity accessors (`identityService.username`/`authUsername`/etc.) no longer dereference the live servlet request - or session on each call. Propagates identity into Grails `task {}` workers automatically, and makes + or session on each call. Propagates identity into Grails `task {}` workers automatically, and + makes `identityService` usable inside WebSocket message handlers. - ## 40.0.3 - 2026-05-20 ### 🐞 Bug Fixes -* Hardened `WebSocketService` channel-routing against malformed channel keys (e.g. presented by older clients that predate the current `{authUsername}|{instanceName}|{uuid}` format). `pushToChannel`, `pushToChannels`, and `hasChannel` now silently drop unparseable keys instead of throwing `ArrayIndexOutOfBoundsException` out of the private `instanceFromKey` helper. +* Hardened `WebSocketService` channel-routing against malformed channel keys (e.g. presented by + older clients that predate the current `{authUsername}|{instanceName}|{uuid}` format). + `pushToChannel`, `pushToChannels`, and `hasChannel` now silently drop unparseable keys instead of + throwing `ArrayIndexOutOfBoundsException` out of the private `instanceFromKey` helper. ## 40.0.2 - 2026-05-19 diff --git a/CLAUDE.md b/CLAUDE.md index 41b5c027..133658f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,9 @@ handles primary-only tasks (e.g., timers with `primaryOnly: true`). Distributed `AppConfig` domain objects store typed config values (`string|int|long|double|bool|json|pwd`) in the database. `ConfigService` provides typed getters. Configs can be marked `clientVisible` -for the JS client. The `pwd` type stores values encrypted via Jasypt. +for the JS client. The `pwd` type stores values encrypted at rest via AES-256-GCM +(`io.xh.hoist.security.crypto.AesTextCipher`), with transparent fallback decryption of legacy +jasypt-format values left over from hoist-core <= v40. ### JSON Handling @@ -302,7 +304,8 @@ unwrapped line and let the viewing tool handle display wrapping. - **Apache POI 5** - Excel/spreadsheet generation - **Micrometer** - Observable metrics with Prometheus and OTLP export - **Kryo 5** - Fast serialization for Hazelcast distributed structures -- **Jasypt** - Encryption for `pwd`-type soft configuration values +- **Spring Security Crypto** - BCrypt password hashing (via `HoistPasswordEncoder`) and JDK-backed + symmetric encryption for `pwd`-type soft config values - **Apache Directory API** - LDAP/Active Directory integration ## Reference Implementation: Toolbox diff --git a/docs/upgrade-notes/v41-upgrade-notes.md b/docs/upgrade-notes/v41-upgrade-notes.md new file mode 100644 index 00000000..db2daca5 --- /dev/null +++ b/docs/upgrade-notes/v41-upgrade-notes.md @@ -0,0 +1,173 @@ +# Hoist Core v41 Upgrade Notes + +> **From:** v40.x → v41.0 | **Released:** TBD | **Difficulty:** 🟢 LOW (unless your app stores +> local user passwords — then 🟡 MEDIUM, ~5 LoC change per User domain class) + +## Overview + +Hoist Core v41 removes the `org.jasypt:jasypt:1.9.3` dependency. Jasypt 1.9.3 was the last +release of the library (published 2014) and it breaks at runtime on JDK 21+ under Spring Boot's +launcher classloader: any call into `BasicPasswordEncryptor.encryptPassword` or +`BasicTextEncryptor.encrypt` throws +`org.jasypt.exceptions.EncryptionInitializationException: Could not perform a valid UNICODE +normalization` — a reflection failure inside its `Normalizer` wrapper around the JDK's +`java.text.Normalizer`. The standard `--add-opens` JVM flags do not resolve it (the bug is in +jasypt's reflective code path, not the JDK module's accessibility), making the library +effectively unusable on modern JDKs. + +Two surfaces are affected: + +1. **Internal:** `AppConfig` used jasypt for both symmetric encryption of `pwd`-typed config + values at rest and a one-way digest used by the admin UI's config-differ. These have moved to + pure-JDK implementations (AES-256-GCM + PBKDF2 and salted SHA-256, respectively) — no app + action required, and **no DB migration needed**: existing encrypted `pwd` values continue to + decrypt transparently via a one-release `LegacyJasyptDecrypter` shim. +2. **App-facing:** Apps that store local user passwords historically imported + `org.jasypt.util.password.BasicPasswordEncryptor` directly in their `User` (or `AppUser`) + domain class — this worked because hoist-core re-exported jasypt via `api` scope. That + transitive dependency is gone. Apps must switch to the new + `io.xh.hoist.security.HoistPasswordEncoder` (~5 LoC change, see below). + +There are no database schema changes in this release. Existing user passwords stored under the +legacy jasypt-default format continue to authenticate without a forced reset — the new encoder's +`matches()` transparently verifies both new BCrypt hashes and legacy jasypt-format hashes. + +## Prerequisites + +Before starting, ensure: + +- [ ] Running hoist-core v40.x (no special intermediate version needed) +- [ ] Your build can resolve `org.springframework.security:spring-security-crypto` (version + managed by the Spring Boot 3.5.x BOM your app already inherits via hoist-core — should + resolve automatically once hoist-core v41 is on your classpath) + +## Upgrade Steps + +### 1. Bump `hoistCoreVersion` in `gradle.properties` + +```properties +hoistCoreVersion=41.0 +``` + +Run `./gradlew assemble` (or your app's equivalent) to pull the new dependency graph. +`org.jasypt:jasypt:1.9.3` is no longer on hoist-core's `api` classpath — any direct imports of +`org.jasypt.*` from your app code will fail to compile after this step. The next two steps +handle the common case. + +### 2. Update your `User` (or `AppUser`) domain class to use `HoistPasswordEncoder` + +If your app has a domain class that stores hashed local-user passwords, find it under +`grails-app/domain/.../user/` (commonly named `User.groovy` or `AppUser.groovy`). It typically +contains a static jasypt `BasicPasswordEncryptor` instance: + +**Before:** + +```groovy +import org.jasypt.util.password.BasicPasswordEncryptor + +class User { + String username + String password + // ... + + private static encryptor = new BasicPasswordEncryptor() + + boolean checkPassword(String plain) { + password ? encryptor.checkPassword(plain, password) : false + } + + def beforeInsert() { encodePassword() } + def beforeUpdate() { if (isDirty('password')) encodePassword() } + + private void encodePassword() { + password = password ? encryptor.encryptPassword(password) : null + } +} +``` + +**After:** + +```groovy +import io.xh.hoist.security.HoistPasswordEncoder + +class User { + String username + String password + // ... + + boolean checkPassword(String plain) { + HoistPasswordEncoder.matches(plain, password) + } + + def beforeInsert() { encodePassword() } + def beforeUpdate() { if (isDirty('password')) encodePassword() } + + private void encodePassword() { + password = HoistPasswordEncoder.encode(password) + } +} +``` + +`HoistPasswordEncoder.matches()` transparently verifies both new BCrypt hashes (written by +`HoistPasswordEncoder.encode`) and legacy jasypt-format hashes (written by older versions of +your app), so existing user records continue to authenticate without intervention. + +### 3. (Optional, recommended) Migrate-on-login for legacy user hashes + +Existing users will keep authenticating via the legacy verification path indefinitely. To +gradually re-hash them with BCrypt as they log in, add a post-authentication hook in your +`AuthenticationService` (or wherever `User.checkPassword` is called): + +```groovy +class AuthenticationService extends BaseAuthenticationService { + + boolean authenticate(String username, String plain) { + def user = User.findByUsername(username) + if (!user?.checkPassword(plain)) return false + + // Opportunistically re-encode legacy hashes once we know the plaintext. + if (HoistPasswordEncoder.isLegacyHash(user.password)) { + user.password = plain // beforeUpdate will hash via HoistPasswordEncoder + user.save(flush: true) + } + return true + } +} +``` + +This is purely housekeeping — the only behavioural difference is that once a user has logged in +post-upgrade, their stored hash transitions from MD5+8-byte-salt (jasypt default) to BCrypt +(industry standard). Adding this hook is encouraged but not required for the upgrade itself. + +### 4. (Internal, no action) `pwd` config values + +`AppConfig` continues to read and write `pwd`-type values transparently. Values written before +this upgrade (Base64-encoded PBEWithMD5AndDES under jasypt's `BasicTextEncryptor`) decrypt +through a `LegacyJasyptDecrypter` shim; values written after the upgrade use AES-256-GCM with a +`$hoist-aes1$` marker prefix. Re-saving any `pwd` config from the admin UI upgrades that record +to the new format. No mass migration is needed; the shim can be removed in a future major +version once all known clients have rolled forward. + +### 5. Verify and ship + +After steps 1–2, your build should compile cleanly with no remaining `import org.jasypt.*` +references in app code. Boot the app — startup that previously failed on `BootStrap` insertion +of users / `pwd`-typed configs (the symptom that originally surfaced this bug) should now +succeed. Existing local-user logins continue to work via the legacy verification path; new +user records and `pwd` configs are written in the new formats. + +## Background — why this change + +The Unicode-normalization failure originates in `org.jasypt.normalization.Normalizer.normalizeWithJavaNormalizer` +(jasypt 1.9.3), which reflectively invokes `java.text.Normalizer.normalize`. Under the +`LaunchedURLClassLoader` Spring Boot uses for bootRun (and any packaged Spring Boot app), the +reflective invocation throws an `InaccessibleObjectException` / `IllegalAccessException` on +JDK 21+. The same call from a standalone JVM (`java -cp jasypt.jar`) on the same JDK works +fine — making this specifically a Spring Boot + JDK 21+ + jasypt 1.9.3 triple-point failure. +Because jasypt has had no release in over a decade and `jasypt-spring-boot` (an unrelated +community shim) still depends on the same broken `jasypt-1.9.3` artifact, the only durable fix +is to remove the dependency. + +The replacements (`HoistPasswordEncoder` / `AesTextCipher` / `SaltedSha256Digester`) prefer +algorithms that are JDK-bundled (PBKDF2, SHA-256, AES-GCM) or Spring-supported (BCrypt) and have +clear migration paths for legacy data. From a027ff61651b1ae125323ace116b14b26deb7b47 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 09:03:54 -0700 Subject: [PATCH 05/10] AppConfig: deterministic config-value digest; rename obfuscation key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related cleanups to the v41 crypto rework, both surfaced by review: * Replace `SaltedSha256Digester` with `ConfigValueDigester`: a deterministic SHA-256 of the plaintext (no salt). The previous per-process random salt broke the Admin Console's Config Diff workflow, which compares `AppConfig.formatForJSON()` payloads across two environments — identical plaintexts produced different digests in different JVMs and showed as spurious diffs on every `pwd` row. The digest is admin-only (HOIST_ADMIN_READER), same trust boundary as the plaintext read path, so dropping the salt does not change the threat model. * Rename `CONFIG_ENCRYPTION_PASSWORD` to `CONFIG_VALUE_OBFUSCATION_KEY` and add a comment block that explicitly names the threat model — this is at-rest obfuscation for low-sensitivity admin UI display, not a confidentiality boundary. Add inline `gitleaks` / `allowlist secret` suppression markers so secret scanners do not alert on a value that has been in this file's source for years and was preserved verbatim for backward compatibility. --- .../io/xh/hoist/config/AppConfig.groovy | 36 ++++++------ .../crypto/ConfigValueDigester.groovy | 30 ++++++++++ .../crypto/SaltedSha256Digester.groovy | 47 ---------------- .../crypto/ConfigValueDigesterSpec.groovy | 56 +++++++++++++++++++ .../crypto/SaltedSha256DigesterSpec.groovy | 53 ------------------ 5 files changed, 103 insertions(+), 119 deletions(-) create mode 100644 src/main/groovy/io/xh/hoist/security/crypto/ConfigValueDigester.groovy delete mode 100644 src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy create mode 100644 src/test/groovy/io/xh/hoist/security/crypto/ConfigValueDigesterSpec.groovy delete mode 100644 src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy diff --git a/grails-app/domain/io/xh/hoist/config/AppConfig.groovy b/grails-app/domain/io/xh/hoist/config/AppConfig.groovy index 22ab9e53..2a2a4b31 100644 --- a/grails-app/domain/io/xh/hoist/config/AppConfig.groovy +++ b/grails-app/domain/io/xh/hoist/config/AppConfig.groovy @@ -11,8 +11,8 @@ import io.xh.hoist.json.JSONFormat import io.xh.hoist.json.JSONParser import io.xh.hoist.log.LogSupport import io.xh.hoist.security.crypto.AesTextCipher +import io.xh.hoist.security.crypto.ConfigValueDigester import io.xh.hoist.security.crypto.LegacyJasyptDecrypter -import io.xh.hoist.security.crypto.SaltedSha256Digester import io.xh.hoist.util.InstanceConfigUtils import io.xh.hoist.util.Utils @@ -20,18 +20,20 @@ import static grails.async.Promises.task class AppConfig implements JSONFormat, LogSupport { - // Hard-coded encryption password — preserved across the jasypt removal in v41 so that - // pwd-typed config values written by hoist-core <= v40 (which used jasypt's - // BasicTextEncryptor under this same password) remain decryptable via the legacy fallback. - // The same string seeds the new AES-GCM cipher via PBKDF2, but new ciphertext is - // distinguishable from legacy by the AesTextCipher.FORMAT_PREFIX marker, so the two - // never collide. (See the v41 upgrade notes for context — sourcing this key from - // instance config is a future enhancement.) - private static final String CONFIG_ENCRYPTION_PASSWORD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' - - static private final AesTextCipher encryptor = new AesTextCipher(CONFIG_ENCRYPTION_PASSWORD) - static private final LegacyJasyptDecrypter legacyDecrypter = new LegacyJasyptDecrypter(CONFIG_ENCRYPTION_PASSWORD) - static private final SaltedSha256Digester digestEncryptor = new SaltedSha256Digester() + // Fixed at-rest obfuscation key for `pwd`-typed config values stored in the Hoist + // application database. This is NOT a confidentiality boundary: anyone with source access can + // decrypt `pwd` values from a DB dump. The `pwd` config type is an admin-UI convenience for + // avoiding plaintext display of API keys etc., not a true secrets-management primitive — real + // secrets belong in instance config / env vars / a dedicated secrets manager. + // + // Preserved verbatim from the pre-v41 jasypt-based implementation so existing ciphertexts + // continue to decrypt without DB migration; see the v41 upgrade notes. + // gitleaks:allow pragma: allowlist secret + private static final String CONFIG_VALUE_OBFUSCATION_KEY = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + + static private final AesTextCipher encryptor = new AesTextCipher(CONFIG_VALUE_OBFUSCATION_KEY) + static private final LegacyJasyptDecrypter legacyDecrypter = new LegacyJasyptDecrypter(CONFIG_VALUE_OBFUSCATION_KEY) + static private final ConfigValueDigester digestEncryptor = new ConfigValueDigester() static List TYPES = ['string', 'int', 'long', 'double', 'bool', 'json', 'pwd'] @@ -151,12 +153,8 @@ class AppConfig implements JSONFormat, LogSupport { digestEncryptor.digest(isEncrypted ? decryptPassword(value) : value) } - /** - * Decrypt a stored pwd-type value. Transparently handles both the current v41+ AES-GCM - * format and legacy values written by hoist-core <= v40 (jasypt's PBEWithMD5AndDES). - * Legacy values are not rewritten in-place by this read path — they upgrade to the new - * format the next time the AppConfig row is saved (e.g. via the admin UI). - */ + // Reads both the current AES-GCM format and legacy jasypt-format values. Legacy values + // upgrade in place the next time the AppConfig row is saved. private static String decryptPassword(String value) { if (AesTextCipher.isHoistFormat(value)) { return encryptor.decrypt(value) diff --git a/src/main/groovy/io/xh/hoist/security/crypto/ConfigValueDigester.groovy b/src/main/groovy/io/xh/hoist/security/crypto/ConfigValueDigester.groovy new file mode 100644 index 00000000..6d63becf --- /dev/null +++ b/src/main/groovy/io/xh/hoist/security/crypto/ConfigValueDigester.groovy @@ -0,0 +1,30 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import groovy.transform.CompileStatic + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * Deterministic SHA-256 digest of `AppConfig` `pwd` values for the Admin Console's Config Diff + * workflow. Identical inputs produce identical output on any JVM running this version of + * hoist-core, so two environments' configs can be compared by JSON-level diff without exposing + * plaintext. Visibility is gated to `HOIST_ADMIN_READER` — the same trust boundary as the + * plaintext read path — so no salt is required. + */ +@CompileStatic +final class ConfigValueDigester { + + /** Returns a Base64-encoded SHA-256 digest of the given input, or null for null input. */ + String digest(String input) { + if (input == null) return null + MessageDigest md = MessageDigest.getInstance('SHA-256') + return Base64.encoder.encodeToString(md.digest(input.getBytes(StandardCharsets.UTF_8))) + } +} diff --git a/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy b/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy deleted file mode 100644 index f7ca5b44..00000000 --- a/src/main/groovy/io/xh/hoist/security/crypto/SaltedSha256Digester.groovy +++ /dev/null @@ -1,47 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ -package io.xh.hoist.security.crypto - -import groovy.transform.CompileStatic - -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.security.SecureRandom - -/** - * One-way SHA-256 digester used internally by {@link io.xh.hoist.config.AppConfig} to render a - * comparable, non-reversible fingerprint of `pwd`-typed config values in the admin UI's - * config-differ display. - * - *

This is not a password storage primitive — it is used purely to produce a - * stable, opaque token so the admin UI can detect whether two `pwd` values are identical - * without revealing the underlying plaintext. For user-password hashing, see - * {@link io.xh.hoist.security.HoistPasswordEncoder}. - * - *

A random salt is generated once per instance and then reused for every call, so two calls - * with the same input on the same digester instance produce identical output (suitable for - * within-process diffing). - */ -@CompileStatic -final class SaltedSha256Digester { - - private final byte[] salt - - SaltedSha256Digester() { - this.salt = new byte[16] - new SecureRandom().nextBytes(salt) - } - - /** Returns a Base64-encoded SHA-256 digest of the given input mixed with this instance's salt. */ - String digest(String input) { - if (input == null) return null - MessageDigest md = MessageDigest.getInstance('SHA-256') - md.update(salt) - md.update(input.getBytes(StandardCharsets.UTF_8)) - return Base64.encoder.encodeToString(md.digest()) - } -} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/ConfigValueDigesterSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/ConfigValueDigesterSpec.groovy new file mode 100644 index 00000000..65aa3a4e --- /dev/null +++ b/src/test/groovy/io/xh/hoist/security/crypto/ConfigValueDigesterSpec.groovy @@ -0,0 +1,56 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +package io.xh.hoist.security.crypto + +import spock.lang.Specification + +class ConfigValueDigesterSpec extends Specification { + + def 'digest is deterministic across distinct instances (process-stable)'() { + // This is the contract that makes admin Config Diff work across two environments — two + // hoist-core JVMs computing the digest of the same plaintext must produce identical output. + given: + def a = new ConfigValueDigester() + def b = new ConfigValueDigester() + + expect: + a.digest('hello') == b.digest('hello') + a.digest('') == b.digest('') + a.digest('a-much-longer-value-that-still-digests-fine') == + b.digest('a-much-longer-value-that-still-digests-fine') + } + + def 'digest matches the published SHA-256 of the input'() { + // Pinning a known SHA-256 output guards against accidental algorithm changes that would + // silently break cross-version Config Diff for apps mid-upgrade. + expect: + new ConfigValueDigester().digest('hello') == + 'LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=' // sha256('hello') base64 + } + + def 'different inputs hash differently'() { + given: + def digester = new ConfigValueDigester() + + expect: + digester.digest('hello') != digester.digest('world') + } + + def 'null input returns null'() { + expect: + new ConfigValueDigester().digest(null) == null + } + + def 'digest output is base64-encoded 32 bytes'() { + when: + def out = new ConfigValueDigester().digest('hello') + + then: + out + Base64.decoder.decode(out).length == 32 // SHA-256 output size + } +} diff --git a/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy deleted file mode 100644 index 94d2e0b0..00000000 --- a/src/test/groovy/io/xh/hoist/security/crypto/SaltedSha256DigesterSpec.groovy +++ /dev/null @@ -1,53 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ -package io.xh.hoist.security.crypto - -import spock.lang.Specification - -class SaltedSha256DigesterSpec extends Specification { - - def 'same instance produces stable output for same input'() { - given: - def digester = new SaltedSha256Digester() - - expect: - digester.digest('hello') == digester.digest('hello') - digester.digest('') == digester.digest('') - } - - def 'different inputs hash differently within an instance'() { - given: - def digester = new SaltedSha256Digester() - - expect: - digester.digest('hello') != digester.digest('world') - } - - def 'different instances produce different output for same input (random salt)'() { - given: - def a = new SaltedSha256Digester() - def b = new SaltedSha256Digester() - - expect: - a.digest('hello') != b.digest('hello') - } - - def 'null input returns null'() { - expect: - new SaltedSha256Digester().digest(null) == null - } - - def 'digest output is non-empty base64'() { - when: - def out = new SaltedSha256Digester().digest('hello') - - then: - out - out.length() > 0 - Base64.decoder.decode(out).length == 32 // SHA-256 - } -} From 18efcfe24ecdcc2b13947ed11b44d8bdd500003c Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 09:04:15 -0700 Subject: [PATCH 06/10] crypto: expand legacy fixture coverage; trim class doc volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegacyJasyptDecrypterSpec now covers empty / single-char / 256-char / NFC / NFD / whitespace fixtures for both the text-decrypt and password-digest paths, each generated by jasypt 1.9.3 on a standalone JVM (the failure mode that motivated the jasypt removal only manifests under Spring Boot's launcher classloader). Adds an explicit NFC↔NFD cross-check confirming the password digest path NFC-normalizes input while the text path does not, mirroring jasypt 1.9.3 behavior. LegacyJasyptDecrypter's class doc now lists the exact cipher and digest parameters it implements against, so future debugging of an in-the-wild legacy value can be cross-referenced to jasypt 1.9.3 source at a glance. AesTextCipher, LegacyJasyptDecrypter, and HoistPasswordEncoder class- and method-level docs trimmed to essentials per review feedback — design rationale and migration narrative live in the v41 upgrade notes. --- .../security/HoistPasswordEncoder.groovy | 62 +++------------ .../security/crypto/AesTextCipher.groovy | 22 ++---- .../crypto/LegacyJasyptDecrypter.groovy | 52 +++++-------- .../crypto/LegacyJasyptDecrypterSpec.groovy | 76 ++++++++++++++----- 4 files changed, 92 insertions(+), 120 deletions(-) diff --git a/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy b/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy index 31681291..29f30cf6 100644 --- a/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy +++ b/src/main/groovy/io/xh/hoist/security/HoistPasswordEncoder.groovy @@ -11,48 +11,14 @@ import io.xh.hoist.security.crypto.LegacyJasyptDecrypter import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder /** - * Centralized password hashing for consuming apps' local user accounts. Wraps Spring Security's - * {@code BCryptPasswordEncoder} so app-level User domain classes don't need to take a direct - * dependency on a specific crypto library. - * - *

This replaces the historical pattern of importing jasypt's {@code BasicPasswordEncryptor} - * directly in each app's User domain class. The jasypt dependency was removed in hoist-core - * v41.0 — it is end-of-life (last released 2014) and breaks at runtime on JDK 21+ under Spring - * Boot's launcher classloader due to a Unicode-normalization reflection bug. See the v41 - * upgrade notes for migration details. - * - *

Typical usage in a User domain class

- *
- * import io.xh.hoist.security.HoistPasswordEncoder
- *
- * class User {
- *     String username
- *     String password
- *
- *     def beforeInsert() { encodePassword() }
- *     def beforeUpdate() { if (isDirty('password')) encodePassword() }
- *
- *     boolean checkPassword(String plain) {
- *         HoistPasswordEncoder.matches(plain, password)
- *     }
- *
- *     private void encodePassword() {
- *         password = HoistPasswordEncoder.encode(password)
- *     }
- * }
- * 
- * - *

Migrating legacy users

- * - * {@link #matches(String, String)} transparently verifies passwords stored in either the new - * BCrypt format or the legacy jasypt format (8-byte-salt MD5, the default produced by - * {@code BasicPasswordEncryptor}). This allows existing users to log in successfully against a - * database populated under jasypt. App code is encouraged to detect legacy hashes after a - * successful match and re-save the user with a freshly {@link #encode encoded} password — see - * {@link #isLegacyHash(String)} and the v41 upgrade notes for a complete pattern. - * - *

Once all users have logged in at least once after the upgrade, the legacy path becomes - * dead code and may be removed in a future release. + * Centralized BCrypt password hashing for consuming apps' local user accounts. Wraps Spring + * Security's {@code BCryptPasswordEncoder} so app `User` domain classes don't take a direct + * dependency on a specific crypto library. Replaces the historical pattern of importing jasypt's + * {@code BasicPasswordEncryptor} (removed in hoist-core v41 — see the v41 upgrade notes). + * + *

{@link #matches(String, String)} transparently verifies both new BCrypt hashes and legacy + * jasypt-default hashes, so pre-upgrade users keep authenticating. Apps can detect legacy hashes + * via {@link #isLegacyHash(String)} and re-encode post-login to gradually migrate them. */ @CompileStatic final class HoistPasswordEncoder { @@ -64,11 +30,7 @@ final class HoistPasswordEncoder { return rawPassword ? ENCODER.encode(rawPassword) : null } - /** - * Verify a plaintext password against an encoded hash. Supports both the current BCrypt - * format and the legacy jasypt-default format (`BasicPasswordEncryptor`). Returns false - * for null/empty inputs. - */ + /** Verify a plaintext against a BCrypt or legacy jasypt hash. False for null/empty inputs. */ static boolean matches(String rawPassword, String encodedPassword) { if (!rawPassword || !encodedPassword) return false if (looksLikeBCrypt(encodedPassword)) { @@ -81,11 +43,7 @@ final class HoistPasswordEncoder { return LegacyJasyptDecrypter.matchesLegacyPasswordHash(rawPassword, encodedPassword) } - /** - * True if the given encoded hash is in the legacy jasypt format and should be re-encoded - * with {@link #encode} on next opportunity (typically post-login). Callers can use this to - * implement migrate-on-login behavior. - */ + /** True if the hash is in the legacy jasypt format and could be re-encoded post-login. */ static boolean isLegacyHash(String encodedPassword) { if (!encodedPassword) return false return !looksLikeBCrypt(encodedPassword) && diff --git a/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy index 99199384..65ef7b9d 100644 --- a/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy +++ b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy @@ -19,15 +19,9 @@ import java.security.SecureRandom /** * Symmetric AES-256-GCM text encryption with a PBKDF2-derived key, used internally by - * {@link io.xh.hoist.config.AppConfig} to protect `pwd`-typed config values at rest. - * - *

Replaces the historical use of jasypt's {@code BasicTextEncryptor}. Values produced by this - * cipher carry the {@link #FORMAT_PREFIX} marker so callers can distinguish them from values - * produced by the legacy encryptor (see - * {@link io.xh.hoist.security.crypto.LegacyJasyptDecrypter}). - * - *

This class is internal to hoist-core. App code should not depend on its output format — - * see {@link io.xh.hoist.security.HoistPasswordEncoder} for the user-facing password hashing API. + * {@link io.xh.hoist.config.AppConfig} to obfuscate `pwd`-typed config values at rest. Output + * carries the {@link #FORMAT_PREFIX} marker so callers can distinguish it from legacy values + * decryptable via {@link LegacyJasyptDecrypter}. */ @CompileStatic final class AesTextCipher { @@ -51,10 +45,7 @@ final class AesTextCipher { this.password = password.toCharArray() } - /** - * Encrypt the given plaintext, returning a single self-contained Base64-encoded string - * prefixed with {@link #FORMAT_PREFIX}. Each call uses a freshly random salt and IV. - */ + /** Encrypt to a self-contained Base64 string with a freshly-random salt and IV per call. */ String encrypt(String plaintext) { if (plaintext == null) throw new IllegalArgumentException('plaintext must not be null') @@ -77,10 +68,7 @@ final class AesTextCipher { return FORMAT_PREFIX + Base64.encoder.encodeToString(packed) } - /** - * Decrypt a value previously produced by {@link #encrypt}. Throws - * {@link IllegalArgumentException} if the input does not carry the expected format prefix. - */ + /** Decrypt a value produced by {@link #encrypt}; throws if the format prefix is missing. */ String decrypt(String ciphertext) { if (ciphertext == null) throw new IllegalArgumentException('ciphertext must not be null') if (!isHoistFormat(ciphertext)) { diff --git a/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy index 6928ee39..d10285a2 100644 --- a/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy +++ b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy @@ -21,28 +21,25 @@ import java.text.Normalizer /** * Read-only, pure-JDK reproduction of jasypt 1.9.3's default symmetric text encryption * ({@code BasicTextEncryptor}) and one-way password hash ({@code BasicPasswordEncryptor}) - * algorithms. Used to verify and migrate legacy values produced by hoist-core <= v40 and apps - * whose User domains historically depended on jasypt directly. + * algorithms. Supports a one-release transition window for legacy values until they migrate + * organically (re-saving a `pwd` config, logging in as a local user, etc.). * - *

This class exists strictly to support a one-release transition window: new ciphertexts and - * hashes are written by {@link AesTextCipher} and - * {@link io.xh.hoist.security.HoistPasswordEncoder}, while legacy values continue to be readable - * via this helper until they have been organically migrated (re-saving a `pwd` config, logging - * in as a local user, etc.). - * - *

Compatibility notes

+ *

Cipher / digest parameters (must match jasypt 1.9.3 exactly)

* - * - *

Both algorithms apply NFC Unicode normalization to inputs before processing, mirroring - * jasypt. (We use {@link java.text.Normalizer} directly here, which on its own is not the - * codepath that breaks under Spring Boot's launcher classloader — jasypt's failure is in its - * reflective wrapper around the same JDK API.) */ @CompileStatic final class LegacyJasyptDecrypter { @@ -63,11 +60,7 @@ final class LegacyJasyptDecrypter { this.password = password.toCharArray() } - /** - * Decrypt a value previously produced by jasypt's {@code BasicTextEncryptor.encrypt(plain)} - * with the same password. The input is the raw Base64 string jasypt wrote into the - * database — no marker prefix. - */ + /** Decrypt a Base64 string produced by jasypt's {@code BasicTextEncryptor.encrypt(plain)}. */ String decrypt(String encodedCiphertext) { if (encodedCiphertext == null) throw new IllegalArgumentException('ciphertext must not be null') @@ -101,10 +94,7 @@ final class LegacyJasyptDecrypter { // Legacy password (one-way digest) verification //---------------------------------------------------------------- - /** - * True if the given plaintext password, hashed with jasypt's {@code BasicPasswordEncryptor} - * algorithm using the salt extracted from {@code legacyEncoded}, matches the encoded digest. - */ + /** True if hashing {@code plain} with the salt embedded in {@code legacyEncoded} matches it. */ static boolean matchesLegacyPasswordHash(String plain, String legacyEncoded) { if (plain == null || legacyEncoded == null) return false byte[] packed @@ -124,11 +114,7 @@ final class LegacyJasyptDecrypter { return MessageDigest.isEqual(expected, actual) } - /** - * Heuristic: the given encoded string has the shape of a jasypt - * {@code BasicPasswordEncryptor} digest (Base64-encoded 24 raw bytes = 8 salt + 16 MD5). - * Used to decide whether to attempt legacy verification or fall through. - */ + /** Shape check: Base64 of exactly 24 raw bytes (8 salt + 16 MD5). */ static boolean looksLikeLegacyPasswordHash(String encoded) { if (encoded == null || encoded.isEmpty()) return false byte[] decoded diff --git a/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy b/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy index 366d1697..409993f8 100644 --- a/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy +++ b/src/test/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypterSpec.groovy @@ -12,16 +12,21 @@ import spock.lang.Specification * Verifies {@link LegacyJasyptDecrypter} correctly reproduces the decode side of jasypt 1.9.3's * {@code BasicTextEncryptor} and {@code BasicPasswordEncryptor} default algorithms. * - *

Test vectors below were captured out-of-band from jasypt 1.9.3 itself (the same - * implementation that wrote them into existing production databases). They are stable encoded - * outputs — each random-salt-prefixed Base64 string decrypts deterministically against the - * given password / verifies against the given plaintext. + *

Test vectors below were generated by jasypt 1.9.3 itself on a standalone JVM (the failure + * mode that originally motivated the jasypt removal manifests only under Spring Boot's launcher + * classloader; standalone JVMs run jasypt fine). The generator script is preserved in the + * fixtures repo under {@code .planning} — see {@code src/test/groovy/README.md}. */ class LegacyJasyptDecrypterSpec extends Specification { // Same password historically hard-coded in AppConfig.groovy for `pwd` value encryption. private static final String APPCONFIG_PWD = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' + // 'caf' + U+00E9 (precomposed é) + private static final String CAFE_NFC = 'caf' + new String([0x00E9] as int[], 0, 1) + // 'caf' + U+0065 'e' + U+0301 combining acute accent + private static final String CAFE_NFD = 'caf' + new String([0x0065, 0x0301] as int[], 0, 2) + def 'decrypts known jasypt BasicTextEncryptor fixtures'() { given: def decrypter = new LegacyJasyptDecrypter(APPCONFIG_PWD) @@ -30,11 +35,31 @@ class LegacyJasyptDecrypterSpec extends Specification { decrypter.decrypt(ciphertext) == plaintext where: - ciphertext | plaintext - 'sYvsDA6GhTFy97SLuc8I3La99s6fkC8S' | 'hello world' - '7j0sw7gOeG8Lr/bZHxARBw==' | '' - 'HNy2JIeH4NnJdyzFJ9hS+lzfPBtbF43fyWrHL/l2YI0=' | 'café é résumé' - '0WLuQW9MxI0qPNOqTwVT60ZPGDl/QU0Y' | 'roundtrip-test' + label | plaintext | ciphertext + 'simple ascii' | 'hello world' | 'sYvsDA6GhTFy97SLuc8I3La99s6fkC8S' + 'simple ascii 2' | 'roundtrip-test' | '0WLuQW9MxI0qPNOqTwVT60ZPGDl/QU0Y' + 'unicode passthrough'| 'café é résumé' | 'HNy2JIeH4NnJdyzFJ9hS+lzfPBtbF43fyWrHL/l2YI0=' + 'empty string' | '' | 'cjMjCRe97tekyE0aIYo6QQ==' + 'single ascii char' | 'a' | 'phwH8NviH8ZnT5aKxCB1Mw==' + 'long (256 chars)' | 'X' * 256 | '+HtRuPOLYBznPrb5ODVMOcGLi3Fd+iQ0d5TbKBbAkpDbF4HTEOZISPiNwIXg3ri8pfk6p3zLsPiAEgdnw7EDUQ6p47cUSAejI4TF9d08GVXJqoIjRH9NJt5yUsYj2rQMpWYpKmJpIFzKktOarwdUr/MuXMQeJnVEpHKY+bOa3D818AuRib5DtsAYrMCF+Zyqe2J6Ag5c6Gg4OAdTCumN0EJxHMclzWjOD3sj9DWdTs7s17D5iCCITV4mu4/wMiPTNDUiRP5OLuj8Zl4pNfXmBS6op7yCXbOBd9lA6PEltSkDrzwPy6o9uSDt2qlpiDNkM4qtCjsY/XBQVw1NaOrBdMiZtLEIfW/ijYhsutZwsCk=' + 'unicode NFC (café)' | CAFE_NFC | 'T2lm+RPHDniI+WHIsk4jTA==' + 'unicode NFD (café)' | CAFE_NFD | '1fK4n5KiCvZFeMvkXHvK+A==' + 'whitespace+special' | 'a b c\n!@#$%^&*()' | '45XphHP8xnG0uQb3oIqmSgcAgpa2VwxdjoHQAbjfYMs=' + } + + def 'text decrypt preserves the exact codepoint sequence (BasicTextEncryptor normalizes only the password, not the plaintext)'() { + // Confirms NFC and NFD plaintexts round-trip distinctly through jasypt — the legacy text + // path does NOT touch plaintext bytes. (The password digest path DOES NFC-normalize the + // input; that contract is exercised separately below.) + given: + def decrypter = new LegacyJasyptDecrypter(APPCONFIG_PWD) + + expect: + decrypter.decrypt('T2lm+RPHDniI+WHIsk4jTA==') == CAFE_NFC + decrypter.decrypt('1fK4n5KiCvZFeMvkXHvK+A==') == CAFE_NFD + CAFE_NFC != CAFE_NFD + CAFE_NFC.length() == 4 + CAFE_NFD.length() == 5 } def 'rejects too-short ciphertext'() { @@ -49,19 +74,34 @@ class LegacyJasyptDecrypterSpec extends Specification { } def 'matches known jasypt BasicPasswordEncryptor digest fixtures'() { - // BasicPasswordEncryptor produces a per-call random salt -- each line below is a different - // encoded form of the SAME plaintext, validating that salt is correctly extracted. + // BasicPasswordEncryptor uses a per-call random salt, so each encoding line is a different + // fixture of the SAME plaintext. The empty/single-char/long/unicode/whitespace cases + // exercise corner inputs that previously surfaced bugs in similar reimplementations. expect: LegacyJasyptDecrypter.matchesLegacyPasswordHash(plain, encoded) where: - plain | encoded - 'secret' | 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv' - 'secret' | '4ZciBiDn8vFKS7KfWKDtrPI2zh65npVq' - 'secret' | '3vCC8qlzR40smcGfXLA795dxcnyRNmoS' - 'secret' | 'mWwYfNJOQyvOVIFEVFJkgro8OyZjvNM4' - 'password' | 'FJ4Cw1xTfDMVAHFv1bmv7OZmSsKnieZD' - 'playwright-test' | '3hrIc7ebBL/NxnkkVk0OWUAMyxRVld3U' + label | plain | encoded + 'simple ascii a' | 'secret' | 'Dn9nYr0xRJwMqmobJYydHNEVGYXDuuRv' + 'simple ascii b' | 'secret' | '4ZciBiDn8vFKS7KfWKDtrPI2zh65npVq' + 'simple ascii c' | 'secret' | '3vCC8qlzR40smcGfXLA795dxcnyRNmoS' + 'simple ascii d' | 'secret' | 'mWwYfNJOQyvOVIFEVFJkgro8OyZjvNM4' + 'password' | 'password' | 'FJ4Cw1xTfDMVAHFv1bmv7OZmSsKnieZD' + 'playwright fixture' | 'playwright-test' | '3hrIc7ebBL/NxnkkVk0OWUAMyxRVld3U' + 'empty string' | '' | 'ChrU+FtaJNXpOqOvR2/MYCCGU/hILcyS' + 'single char' | 'a' | '7DJDajtcTZ6jShReh/inrfJ5ZXCitGfr' + 'long (256 chars)' | 'X' * 256 | 'sdvN0Lh8lNANx56RJlYycFBxvbieeoU+' + 'unicode NFC (café)' | CAFE_NFC | '5skexPacgCES39VwmXE3aH7vIzKBWb7s' + 'unicode NFD (café)' | CAFE_NFD | 'dr9D3fwsSqy/7hE6gJeEbodx7TI5gpTC' + 'whitespace+special' | 'a b c\n!@#$%^&*()' | 'KUJ3pGX2vEbcCgE5Wck4b9m7mFV8e89n' + } + + def 'NFC-encoded digest verifies an NFD plaintext (jasypt-compatible NFC normalization)'() { + // The encoded fixture below was generated from the NFC form of "café". The NFD form is + // a distinct codepoint sequence but normalizes to the same NFC form before hashing, so + // verification must succeed. + expect: + LegacyJasyptDecrypter.matchesLegacyPasswordHash(CAFE_NFD, '5skexPacgCES39VwmXE3aH7vIzKBWb7s') } def 'rejects wrong plaintext for legacy digest'() { From 437735f16de2bb7216d68a7fa161cd77ad3c9a40 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 21 May 2026 09:06:53 -0700 Subject: [PATCH 07/10] Document the new test infrastructure; tighten CHANGELOG + upgrade notes * Added src/test/groovy/README.md covering how to run the suite, the scope of what belongs here (pure-JDK only, no Grails context), naming conventions, and the regeneration recipe for the jasypt-1.9.3 legacy-format fixtures pinned in the new crypto specs. * Pointed CLAUDE.md at the test README and listed `./gradlew test` in the build-commands section so AI agents discover the testing surface. * Rewrote the v41 CHANGELOG entries to single-sentence bullets per docs/changelog-format.md; the previous wrapped paragraphs duplicated detail already covered by the upgrade notes. * Added a paragraph to the v41 upgrade notes naming the obfuscation-key threat model explicitly and noting that the key is unchanged from prior releases. --- CHANGELOG.md | 48 ++++-------------- CLAUDE.md | 9 ++++ docs/upgrade-notes/v41-upgrade-notes.md | 6 +++ src/test/groovy/README.md | 67 +++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 37 deletions(-) create mode 100644 src/test/groovy/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 887c8586..fb29d1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,43 +5,17 @@ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - only affects apps with local user passwords) See [`docs/upgrade-notes/v41-upgrade-notes.md`](docs/upgrade-notes/v41-upgrade-notes.md) for -detailed migration instructions. - -* Removed the `org.jasypt:jasypt:1.9.3` dependency. Jasypt was end-of-life (last released 2014) and - broke at runtime on JDK 21+ under Spring Boot's launcher classloader due to a - Unicode-normalization reflection bug — observable as - `EncryptionInitializationException: Could not perform a valid UNICODE normalization` thrown from - `BasicPasswordEncryptor.encryptPassword` or `BasicTextEncryptor.encrypt` during `bootRun`. Apps - that imported `org.jasypt.util.password.BasicPasswordEncryptor` in their User domain classes ( - transitively via hoist-core's `api` scope) must switch to the new - `io.xh.hoist.security.HoistPasswordEncoder` — see the upgrade notes for a drop-in snippet. The - encoder's `matches()` method transparently verifies both new BCrypt hashes and legacy - jasypt-format hashes, so existing user passwords continue to work without a forced reset; - `isLegacyHash()` lets apps re-encode on next successful login if desired. - -### ⚙️ Technical - -* Replaced jasypt's internal use in `AppConfig` with pure-JDK AES-256-GCM (key derived via - PBKDF2WithHmacSHA256) for symmetric `pwd`-value encryption and a salted SHA-256 digester for the - admin UI's config-diff fingerprint. New ciphertexts carry the `$hoist-aes1$` marker prefix so they - are unambiguously distinguishable from legacy values; a one-release `LegacyJasyptDecrypter` shim - handles in-place read of pre-v41 ciphertexts written by the previous `BasicTextEncryptor` - codepath, with no DB migration required. -* Added `io.xh.hoist.security.HoistPasswordEncoder`, a thin wrapper around Spring Security's - `BCryptPasswordEncoder` for app-level local-user password hashing. Replaces the historical - convention of importing jasypt directly in each app's User domain class. -* Added `org.springframework.security:spring-security-crypto` (crypto module only — no controllers, - filters, or other Spring Security surface area pulled in). Version managed by the Spring Boot BOM. -* Added a `src/test/groovy/` tree with Spock specs covering the new crypto utilities — first unit - tests landed in hoist-core. Test fixtures include known-good ciphertexts and digests generated - out-of-band by jasypt 1.9.3 to verify legacy compatibility. -* Reworked identity resolution onto an explicit per-thread `HoistIdentity` cache, installed at - every framework thread-entry point (`HoistFilter`, `HoistWebSocketHandler`, async `task` workers - via a new `HoistPromiseFactory`, and `ClusterTask`). Identity accessors - (`identityService.username`/`authUsername`/etc.) no longer dereference the live servlet request - or session on each call. Propagates identity into Grails `task {}` workers automatically, and - makes - `identityService` usable inside WebSocket message handlers. +detailed, step-by-step upgrade instructions with before/after code examples. + +* Removed the `org.jasypt:jasypt:1.9.3` dependency. Apps importing `org.jasypt.*` (typically `BasicPasswordEncryptor` in a `User` domain class) must switch to `io.xh.hoist.security.HoistPasswordEncoder`. Legacy user-password hashes continue to verify transparently. + +### ⚙️ Technical + +* Replaced jasypt's internal use in `AppConfig` with pure-JDK AES-256-GCM (PBKDF2 key) for `pwd`-value encryption and a deterministic SHA-256 of plaintext for the admin Config Diff fingerprint. New ciphertexts carry a `$hoist-aes1$` marker; pre-v41 values continue to read through a one-release `LegacyJasyptDecrypter` shim with no DB migration. +* Added `io.xh.hoist.security.HoistPasswordEncoder`, a thin BCrypt wrapper for app `User` domain classes. Verifies both new BCrypt hashes and legacy jasypt-default hashes; `isLegacyHash()` supports opportunistic re-encoding on login. +* Added `org.springframework.security:spring-security-crypto` (crypto module only). Version managed by the Spring Boot BOM. +* Added `src/test/groovy/` with Spock unit tests covering the new crypto utilities. First unit tests in hoist-core; see `src/test/groovy/README.md` for conventions. +* Reworked identity resolution onto an explicit per-thread `HoistIdentity` cache installed at every framework thread-entry point (`HoistFilter`, `HoistWebSocketHandler`, async `task` workers via a new `HoistPromiseFactory`, and `ClusterTask`). Identity accessors no longer dereference the live servlet request on each call; identity now propagates into Grails `task {}` workers and is usable inside WebSocket message handlers. ## 40.0.3 - 2026-05-20 diff --git a/CLAUDE.md b/CLAUDE.md index 133658f4..7ce443b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,11 +88,20 @@ enable it for Claude Code. ```bash ./gradlew assemble # Compile all sources (Groovy + Java) and build the JAR ./gradlew clean assemble # Clean rebuild +./gradlew test # Run Spock unit tests under src/test/groovy +./gradlew clean build # Full build incl. test ``` This is a plugin — `bootRun` is not supported. To run locally, use a wrapper app project that includes hoist-core as a dependency. +## Tests + +Spock-based unit tests live under `src/test/groovy/io/xh/hoist/` and target pure-JDK code only — +no Grails context, no DB, no clustering. See +[`src/test/groovy/README.md`](src/test/groovy/README.md) for conventions, how to add specs, and +notes on the jasypt-1.9.3 legacy fixture data used by the `io.xh.hoist.security.*` specs. + ## Source Layout ``` diff --git a/docs/upgrade-notes/v41-upgrade-notes.md b/docs/upgrade-notes/v41-upgrade-notes.md index db2daca5..5446af01 100644 --- a/docs/upgrade-notes/v41-upgrade-notes.md +++ b/docs/upgrade-notes/v41-upgrade-notes.md @@ -148,6 +148,12 @@ through a `LegacyJasyptDecrypter` shim; values written after the upgrade use AES to the new format. No mass migration is needed; the shim can be removed in a future major version once all known clients have rolled forward. +The hard-coded `AppConfig` obfuscation key (`CONFIG_VALUE_OBFUSCATION_KEY` in source) is +unchanged from prior releases, so existing `pwd` ciphertexts decrypt without action. As +before, this key is at-rest obfuscation for low-sensitivity admin-UI display, not a +confidentiality boundary — anyone with source access can decrypt `pwd` values from a DB dump. +Real secrets belong in instance config / env vars / a dedicated secrets manager. + ### 5. Verify and ship After steps 1–2, your build should compile cleanly with no remaining `import org.jasypt.*` diff --git a/src/test/groovy/README.md b/src/test/groovy/README.md new file mode 100644 index 00000000..318ee6a8 --- /dev/null +++ b/src/test/groovy/README.md @@ -0,0 +1,67 @@ +# hoist-core unit tests + +Spock-based unit tests for pure-JDK classes in hoist-core. First introduced alongside the v41 +jasypt removal to cover the new `io.xh.hoist.security.*` crypto utilities. + +## Running + +From the repo root: + +```bash +./gradlew test # all unit tests +./gradlew test --tests # one spec +``` + +CI runs `./gradlew clean build`, which includes `test`. + +## Adding a spec + +- Mirror the package path of the class under test (e.g. + `src/main/groovy/io/xh/hoist/foo/Bar.groovy` → `src/test/groovy/io/xh/hoist/foo/BarSpec.groovy`). +- Name the file `Spec.groovy`. +- Extend `spock.lang.Specification`. +- Use `where:` blocks for table-driven cases — they produce clearer failure output than asserting + inside a loop. + +## Scope + +These specs target **pure-JDK code** — classes with no dependency on a Grails application +context, the test database, Hazelcast, or `BootStrap`. Examples that fit: + +- Crypto / encoding utilities. +- Pure data transformations and parsers. +- Static helpers in `src/main/groovy/io/xh/hoist/util/`. + +Anything that needs Grails wiring (services, controllers, GORM, clustering) does **not** belong +here. There is no integration-test harness in hoist-core yet — for now, integration coverage +lives downstream in consuming apps' own Grails integration suites (e.g. Toolbox). + +## Fixture data — jasypt 1.9.3 legacy values + +`LegacyJasyptDecrypterSpec` and `HoistPasswordEncoderSpec` pin known ciphertexts and password +digests produced by jasypt 1.9.3 itself. Jasypt is no longer on the project's classpath, so +fixtures are regenerated out-of-band on a standalone JVM (jasypt 1.9.3 runs fine outside Spring +Boot's launcher classloader). The reusable generator script: + +```groovy +@Grab(group='org.jasypt', module='jasypt', version='1.9.3') +import org.jasypt.util.text.BasicTextEncryptor +import org.jasypt.util.password.BasicPasswordEncryptor + +def pwd = 'dsd899s_*)jsk9dsl2fd223hpdj32))I@333' // AppConfig obfuscation key +def textEnc = new BasicTextEncryptor(); textEnc.setPassword(pwd) +def pwdEnc = new BasicPasswordEncryptor() + +println textEnc.encrypt('hello world') // ciphertext for the AppConfig pwd path +println pwdEnc.encryptPassword('secret') // hash for the local-user-password path +``` + +Save as `GenerateFixtures.groovy` and run with `groovy GenerateFixtures.groovy`. + +For NFC vs NFD coverage, construct the two forms via codepoints (not source literals — IDEs +silently normalize): + +```groovy +def cafeNFC = 'caf' + new String([0x00E9] as int[], 0, 1) // U+00E9 +def cafeNFD = 'caf' + new String([0x0065, 0x0301] as int[], 0, 2) // 'e' + combining acute +``` From 8c77624c4ee1b3acc14620350b95263aaee80ee0 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 26 May 2026 21:55:02 -0700 Subject: [PATCH 08/10] Doc fixes: stale references; scope disclaimers on crypto classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v41-upgrade-notes.md: replace two stale references to the pre-rename `SaltedSha256Digester` / "salted SHA-256" with the current `ConfigValueDigester` / "deterministic SHA-256" naming. Caught during PR write-up; doc-only, no runtime impact. * AesTextCipher: class-level Groovydoc now explicitly states the intended scope (at-rest obfuscation of `pwd` AppConfig values behind a source-visible key) and warns that it is NOT a confidentiality boundary or a general-purpose secrets primitive — direct readers should reach for instance config / env vars / a real secrets manager instead. * LegacyJasyptDecrypter: class-level Groovydoc now flags that the reproduced jasypt algorithms (PBE-MD5-DES, MD5+8-byte-salt) are obsolete and that the class is a one-release migration shim, not for new use cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/upgrade-notes/v41-upgrade-notes.md | 4 ++-- .../xh/hoist/security/crypto/AesTextCipher.groovy | 14 ++++++++++---- .../security/crypto/LegacyJasyptDecrypter.groovy | 7 +++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/upgrade-notes/v41-upgrade-notes.md b/docs/upgrade-notes/v41-upgrade-notes.md index 5446af01..468b97b6 100644 --- a/docs/upgrade-notes/v41-upgrade-notes.md +++ b/docs/upgrade-notes/v41-upgrade-notes.md @@ -19,7 +19,7 @@ Two surfaces are affected: 1. **Internal:** `AppConfig` used jasypt for both symmetric encryption of `pwd`-typed config values at rest and a one-way digest used by the admin UI's config-differ. These have moved to - pure-JDK implementations (AES-256-GCM + PBKDF2 and salted SHA-256, respectively) — no app + pure-JDK implementations (AES-256-GCM + PBKDF2 and deterministic SHA-256, respectively) — no app action required, and **no DB migration needed**: existing encrypted `pwd` values continue to decrypt transparently via a one-release `LegacyJasyptDecrypter` shim. 2. **App-facing:** Apps that store local user passwords historically imported @@ -174,6 +174,6 @@ Because jasypt has had no release in over a decade and `jasypt-spring-boot` (an community shim) still depends on the same broken `jasypt-1.9.3` artifact, the only durable fix is to remove the dependency. -The replacements (`HoistPasswordEncoder` / `AesTextCipher` / `SaltedSha256Digester`) prefer +The replacements (`HoistPasswordEncoder` / `AesTextCipher` / `ConfigValueDigester`) prefer algorithms that are JDK-bundled (PBKDF2, SHA-256, AES-GCM) or Spring-supported (BCrypt) and have clear migration paths for legacy data. diff --git a/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy index 65ef7b9d..72fdf630 100644 --- a/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy +++ b/src/main/groovy/io/xh/hoist/security/crypto/AesTextCipher.groovy @@ -18,10 +18,16 @@ import java.nio.charset.StandardCharsets import java.security.SecureRandom /** - * Symmetric AES-256-GCM text encryption with a PBKDF2-derived key, used internally by - * {@link io.xh.hoist.config.AppConfig} to obfuscate `pwd`-typed config values at rest. Output - * carries the {@link #FORMAT_PREFIX} marker so callers can distinguish it from legacy values - * decryptable via {@link LegacyJasyptDecrypter}. + * Symmetric AES-256-GCM text encryption with a PBKDF2-derived key. Output carries the + * {@link #FORMAT_PREFIX} marker so callers can distinguish it from legacy values decryptable via + * {@link LegacyJasyptDecrypter}. + * + *

Intended scope — NOT a general-purpose secrets primitive

+ * Used internally by {@link io.xh.hoist.config.AppConfig} to obfuscate `pwd`-typed config values + * at rest behind a fixed, source-visible key. This is at-rest obfuscation against casual DB-dump + * reading — it is NOT a confidentiality boundary, and anyone with source access can decrypt. + * Do not adopt this class for real secrets: use instance config, environment variables, or a + * dedicated secrets manager. */ @CompileStatic final class AesTextCipher { diff --git a/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy index d10285a2..e05a41bd 100644 --- a/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy +++ b/src/main/groovy/io/xh/hoist/security/crypto/LegacyJasyptDecrypter.groovy @@ -24,6 +24,13 @@ import java.text.Normalizer * algorithms. Supports a one-release transition window for legacy values until they migrate * organically (re-saving a `pwd` config, logging in as a local user, etc.). * + *

Intended scope — migration shim only

+ * The sole purpose of this class is reading pre-v41 `pwd` AppConfig ciphertexts and verifying + * legacy local-user password hashes long enough for them to be re-written in modern formats. + * The algorithms reproduced here (PBE-MD5-DES, MD5+8-byte-salt) are obsolete by modern + * standards and offer no meaningful security; the AppConfig use case is at-rest obfuscation + * only (see {@link AesTextCipher}). Do not adopt this class for any new use case. + * *

Cipher / digest parameters (must match jasypt 1.9.3 exactly)

*