View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.nio.charset.StandardCharsets;
19  import java.security.MessageDigest;
20  import java.security.NoSuchAlgorithmException;
21  import java.util.Collections;
22  import java.util.LinkedHashMap;
23  import java.util.Locale;
24  import java.util.Map;
25  
26  import org.apache.logging.log4j.LogManager;
27  import org.apache.logging.log4j.Logger;
28  import org.codelibs.core.lang.StringUtil;
29  import org.codelibs.fess.crypto.bcrypt.BCrypt;
30  import org.codelibs.fess.mylasta.direction.FessConfig;
31  import org.codelibs.fess.util.ComponentUtil;
32  
33  import jakarta.annotation.PostConstruct;
34  
35  /**
36   * Facade for password hashing and verification.
37   * <p>
38   * Implements a prefix-based (<code>{id}hash</code>) delegating scheme that is
39   * functionally equivalent to Spring Security's
40   * {@code PasswordEncoderFactories.createDelegatingPasswordEncoder()} (v5.8
41   * defaults), without pulling in any external dependency. The default encoder
42   * is BCrypt (cost 10, <code>$2a$</code>) and legacy pre-prefix hashes are
43   * still verifiable via {@code app.digest.algorithm} (sha256/sha512/md5 hex
44   * lower-case, no salt).
45   */
46  public class PasswordHashHelper {
47  
48      /** Logger instance for this class. */
49      private static final Logger logger = LogManager.getLogger(PasswordHashHelper.class);
50  
51      /** Prefix marker (start) of the encoder id. */
52      protected static final String PREFIX = "{";
53  
54      /** Prefix marker (end) of the encoder id. */
55      protected static final String SUFFIX = "}";
56  
57      /** Encoder id for BCrypt. */
58      protected static final String ID_BCRYPT = "bcrypt";
59  
60      /** Full id prefix token for BCrypt (<code>{bcrypt}</code>). */
61      public static final String BCRYPT_PREFIX = PREFIX + ID_BCRYPT + SUFFIX;
62  
63      /**
64       * Plaintext seed used to generate the dummy BCrypt hash consumed by
65       * {@link #applyTimingPadding()}. The value is not secret and is not
66       * user-supplied; it exists only to produce a well-formed hash for
67       * timing-attack equalisation.
68       */
69      protected static final String DUMMY_BCRYPT_SEED = "__fess_timing_guard__";
70  
71      /** Registered encoders keyed by id. Populated lazily and read-only after init. */
72      protected volatile Map<String, PasswordEncoder> encoders;
73  
74      /**
75       * Cached dummy BCrypt hash used by {@link #applyTimingPadding()} as a
76       * timing-attack countermeasure. Built at container start-up (see
77       * {@link #init()}) using the currently configured
78       * {@code app.password.bcrypt.cost}, so that the dummy path uses the same
79       * BCrypt cost as real user records. Stored in a {@code volatile} field
80       * with double-checked locking so a runtime cost bump (via config reload)
81       * can still trigger a one-time regeneration without synchronisation on
82       * the hot path.
83       */
84      protected volatile String dummyBcryptHash;
85  
86      /**
87       * Default constructor.
88       */
89      public PasswordHashHelper() {
90          // no-op
91      }
92  
93      /**
94       * Validates the password configuration eagerly at container start-up so
95       * that a misconfigured {@code app.password.algorithm} fails fast instead of
96       * surfacing on the first user login. Also forces the encoder map to build
97       * so its construction cost is not paid on the login critical path.
98       */
99      @PostConstruct
100     public void init() {
101         // resolveIdForEncode() internally validates against getEncoders(),
102         // which throws IllegalStateException on unknown algorithms.
103         resolveIdForEncode();
104         // Eagerly build the dummy BCrypt hash so that (a) any misconfiguration
105         // of the BCrypt cost surfaces at start-up (fail-fast), and (b) the
106         // first timing-padding call on the login hot path does not pay the
107         // cost of generating it.
108         getDummyBcryptHash();
109     }
110 
111     /**
112      * Consumes approximately one BCrypt verification worth of CPU to equalise
113      * authentication latency across success and failure paths. Callers should
114      * invoke this on the failure branch when the normal {@link #matches}
115      * invocation did not already pay a BCrypt cost (for example: user not
116      * found, legacy hex-digest stored value, unknown-id prefix).
117      *
118      * <p>This method never throws: if the dummy hash cannot be produced for
119      * any reason, it returns silently rather than leaking a distinguishable
120      * exception to the caller.</p>
121      */
122     public void applyTimingPadding() {
123         final String dummy = getDummyBcryptHash();
124         if (dummy == null) {
125             return;
126         }
127         try {
128             BCrypt.checkpw(DUMMY_BCRYPT_SEED, dummy.substring(BCRYPT_PREFIX.length()));
129         } catch (final RuntimeException e) {
130             if (logger.isDebugEnabled()) {
131                 logger.debug("timing-padding verification failed", e);
132             }
133         }
134     }
135 
136     /**
137      * Indicates whether the supplied stored hash is in a format whose
138      * verification via {@link #matches(String, String)} already pays a BCrypt
139      * cost (or equivalent), meaning the caller does <em>not</em> need to add
140      * {@link #applyTimingPadding()} on the failure branch.
141      *
142      * @param storedPassword the stored (hashed) password value
143      * @return {@code true} if the id prefix denotes a timing-safe encoder
144      *         (currently only {@code {bcrypt}}); {@code false} for legacy
145      *         unprefixed values and unknown prefixes
146      */
147     public boolean isTimingSafeHash(final String storedPassword) {
148         if (storedPassword == null || storedPassword.isEmpty()) {
149             return false;
150         }
151         return ID_BCRYPT.equals(extractId(storedPassword));
152     }
153 
154     /**
155      * Returns the dummy BCrypt hash used by {@link #applyTimingPadding()},
156      * generating it lazily on first access using the configured BCrypt cost.
157      * Uses double-checked locking so subsequent calls are lock-free.
158      *
159      * @return a valid {@code {bcrypt}$2a$...} string that will never match any
160      *         real user-supplied plaintext, or {@code null} if generation
161      *         failed (logged only)
162      */
163     protected String getDummyBcryptHash() {
164         String v = dummyBcryptHash;
165         if (v == null) {
166             synchronized (this) {
167                 v = dummyBcryptHash;
168                 if (v == null) {
169                     try {
170                         final int cost = resolveBcryptCost();
171                         v = BCRYPT_PREFIX + BCrypt.hashpw(DUMMY_BCRYPT_SEED, BCrypt.gensalt(cost));
172                         dummyBcryptHash = v;
173                     } catch (final RuntimeException e) {
174                         logger.warn("Failed to build dummy BCrypt hash for timing padding", e);
175                         return null;
176                     }
177                 }
178             }
179         }
180         return v;
181     }
182 
183     /**
184      * Resolves the effective BCrypt cost, clamped into the jBCrypt-valid
185      * range [4, 30]. Extracted from {@link BcryptPasswordEncoder} so the
186      * dummy-hash path uses the exact same value as real user records.
187      *
188      * @return the effective BCrypt cost in the range [4, 30]
189      */
190     protected int resolveBcryptCost() {
191         final Integer cost = ComponentUtil.getFessConfig().getAppPasswordBcryptCostAsInteger();
192         if (cost == null) {
193             return 10;
194         }
195         final int c = cost.intValue();
196         if (c < 4) {
197             return 4;
198         }
199         if (c > 30) {
200             return 30;
201         }
202         return c;
203     }
204 
205     /**
206      * Encodes the raw password using the configured default algorithm.
207      *
208      * @param rawPassword the plain-text password (must not be {@code null})
209      * @return the encoded password prefixed with <code>{id}</code>
210      * @throws NullPointerException if {@code rawPassword} is null
211      * @throws IllegalStateException if the configured algorithm is unknown
212      */
213     public String encode(final String rawPassword) {
214         if (rawPassword == null) {
215             throw new NullPointerException("rawPassword must not be null");
216         }
217         final String idForEncode = resolveIdForEncode();
218         final PasswordEncoder encoder = getEncoders().get(idForEncode);
219         if (encoder == null) {
220             throw new IllegalStateException("Unknown password algorithm: " + idForEncode);
221         }
222         return PREFIX + idForEncode + SUFFIX + encoder.encode(rawPassword);
223     }
224 
225     /**
226      * Verifies that the raw password matches the stored representation.
227      * Falls back to legacy hex digest verification when the stored value
228      * carries no <code>{id}</code> prefix.
229      *
230      * @param rawPassword the plain-text password being verified
231      * @param storedPassword the stored (hashed) password
232      * @return {@code true} if the password matches, {@code false} otherwise
233      *         (including null/empty inputs and unknown prefixes)
234      */
235     public boolean matches(final String rawPassword, final String storedPassword) {
236         if (rawPassword == null || storedPassword == null || storedPassword.isEmpty()) {
237             return false;
238         }
239         final String id = extractId(storedPassword);
240         if (id == null) {
241             // Legacy: unprefixed hex digest governed by app.digest.algorithm.
242             return matchesLegacy(rawPassword, storedPassword);
243         }
244         final PasswordEncoder encoder = getEncoders().get(id);
245         if (encoder == null) {
246             if (logger.isDebugEnabled()) {
247                 logger.debug("unknown password prefix id={}", id);
248             }
249             return false;
250         }
251         final String encodedPayload = storedPassword.substring(id.length() + 2);
252         try {
253             return encoder.matches(rawPassword, encodedPayload);
254         } catch (final RuntimeException e) {
255             if (logger.isDebugEnabled()) {
256                 logger.debug("failed to verify password id={}", id, e);
257             }
258             return false;
259         }
260     }
261 
262     /**
263      * Determines whether the stored password should be re-encoded using the
264      * currently configured algorithm and parameters.
265      *
266      * @param storedPassword the stored (hashed) password
267      * @return {@code true} if re-encoding is recommended
268      */
269     public boolean upgradeEncoding(final String storedPassword) {
270         final FessConfig fessConfig = ComponentUtil.getFessConfig();
271         if (!fessConfig.isAppPasswordUpgradeEnabled()) {
272             return false;
273         }
274         if (storedPassword == null || storedPassword.isEmpty()) {
275             return false;
276         }
277         final String idForEncode = resolveIdForEncode();
278         final String id = extractId(storedPassword);
279         if (id == null) {
280             // Legacy hashes should always be upgraded.
281             return true;
282         }
283         if (!getEncoders().containsKey(id)) {
284             // Unknown prefix: matches() would have returned false, so there is
285             // nothing sensible to re-encode from. Decline the upgrade
286             // defensively instead of silently re-hashing on an unverified path.
287             if (logger.isDebugEnabled()) {
288                 logger.debug("declining upgrade for unknown password prefix id={}", id);
289             }
290             return false;
291         }
292         if (!id.equals(idForEncode)) {
293             return true;
294         }
295         if (ID_BCRYPT.equals(id)) {
296             final int currentCost = parseBcryptCost(storedPassword.substring(id.length() + 2));
297             final Integer targetCost = fessConfig.getAppPasswordBcryptCostAsInteger();
298             if (currentCost >= 0 && targetCost != null && currentCost < targetCost.intValue()) {
299                 return true;
300             }
301         }
302         return false;
303     }
304 
305     /**
306      * Resolves the encoder id used for new passwords.
307      *
308      * @return the configured encoder id (lower-cased)
309      */
310     protected String resolveIdForEncode() {
311         final String configured = ComponentUtil.getFessConfig().getAppPasswordAlgorithm();
312         if (StringUtil.isBlank(configured)) {
313             throw new IllegalStateException("app.password.algorithm is not configured");
314         }
315         final String id = configured.toLowerCase(Locale.ROOT);
316         if (!getEncoders().containsKey(id)) {
317             throw new IllegalStateException("Unsupported password algorithm: " + id);
318         }
319         return id;
320     }
321 
322     /**
323      * Extracts the <code>{id}</code> from a stored password value, or
324      * {@code null} if it does not carry a prefix.
325      *
326      * @param storedPassword the stored password value (possibly prefixed)
327      * @return the id inside <code>{...}</code>, or {@code null} if not prefixed
328      */
329     protected String extractId(final String storedPassword) {
330         if (storedPassword == null || !storedPassword.startsWith(PREFIX)) {
331             return null;
332         }
333         final int end = storedPassword.indexOf(SUFFIX, PREFIX.length());
334         if (end < 0) {
335             return null;
336         }
337         return storedPassword.substring(PREFIX.length(), end);
338     }
339 
340     /**
341      * Parses the cost (log rounds) component of a BCrypt hash
342      * (<code>$2a$NN$...</code>).
343      *
344      * @param bcryptHash the BCrypt hash string (without any <code>{id}</code> prefix)
345      * @return the cost value, or {@code -1} if parsing fails
346      */
347     protected int parseBcryptCost(final String bcryptHash) {
348         if (bcryptHash == null || bcryptHash.length() < 7 || bcryptHash.charAt(0) != '$') {
349             return -1;
350         }
351         final int firstDollar = 0;
352         final int secondDollar = bcryptHash.indexOf('$', firstDollar + 1);
353         if (secondDollar < 0) {
354             return -1;
355         }
356         final int thirdDollar = bcryptHash.indexOf('$', secondDollar + 1);
357         if (thirdDollar < 0) {
358             return -1;
359         }
360         try {
361             return Integer.parseInt(bcryptHash.substring(secondDollar + 1, thirdDollar));
362         } catch (final NumberFormatException e) {
363             return -1;
364         }
365     }
366 
367     /**
368      * Verifies a legacy (unprefixed) hex digest using
369      * {@code app.digest.algorithm} with a constant-time comparison.
370      *
371      * @param rawPassword the plain-text password
372      * @param storedPassword the stored legacy hex digest
373      * @return {@code true} if matched
374      */
375     protected boolean matchesLegacy(final String rawPassword, final String storedPassword) {
376         final String algorithm = ComponentUtil.getFessConfig().getAppDigestAlgorithm();
377         if (StringUtil.isBlank(algorithm)) {
378             return false;
379         }
380         final String jcaName = toJcaDigestName(algorithm);
381         if (jcaName == null) {
382             if (logger.isDebugEnabled()) {
383                 logger.debug("unsupported legacy digest algorithm={}", algorithm);
384             }
385             return false;
386         }
387         final byte[] computed;
388         try {
389             final MessageDigest md = MessageDigest.getInstance(jcaName);
390             computed = md.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
391         } catch (final NoSuchAlgorithmException e) {
392             if (logger.isDebugEnabled()) {
393                 logger.debug("legacy digest algorithm not available={}", algorithm, e);
394             }
395             return false;
396         }
397         final byte[] expected = hexDecodeLowerCase(storedPassword);
398         if (expected == null) {
399             return false;
400         }
401         final boolean matched = MessageDigest.isEqual(computed, expected);
402         if (matched && "md5".equals(algorithm.toLowerCase(Locale.ROOT)) && logger.isWarnEnabled()) {
403             logger.warn("Insecure legacy MD5 password matched. "
404                     + "This user will be re-hashed on next login if app.password.upgrade is enabled.");
405         }
406         return matched;
407     }
408 
409     /**
410      * Translates the short algorithm id (sha256/sha512/md5) recorded in
411      * {@code app.digest.algorithm} into the JCA standard name.
412      *
413      * @param algorithm the short algorithm id
414      * @return the JCA digest name, or {@code null} if unsupported
415      */
416     protected String toJcaDigestName(final String algorithm) {
417         final String lower = algorithm.toLowerCase(Locale.ROOT);
418         switch (lower) {
419         case "sha256":
420         case "sha-256":
421             return "SHA-256";
422         case "sha512":
423         case "sha-512":
424             return "SHA-512";
425         case "md5":
426             return "MD5";
427         default:
428             return null;
429         }
430     }
431 
432     /**
433      * Decodes a lower-case hex string into bytes. Upper-case input is also
434      * accepted for robustness — constant-time comparison still succeeds.
435      *
436      * @param hex the hex-encoded input
437      * @return the decoded bytes, or {@code null} if the input is not a valid even-length hex string
438      */
439     protected byte[] hexDecodeLowerCase(final String hex) {
440         if (hex == null) {
441             return null;
442         }
443         final int len = hex.length();
444         if ((len & 1) != 0) {
445             return null;
446         }
447         final byte[] out = new byte[len / 2];
448         for (int i = 0; i < len; i += 2) {
449             final int hi = Character.digit(hex.charAt(i), 16);
450             final int lo = Character.digit(hex.charAt(i + 1), 16);
451             if (hi < 0 || lo < 0) {
452                 return null;
453             }
454             out[i / 2] = (byte) ((hi << 4) | lo);
455         }
456         return out;
457     }
458 
459     /**
460      * Returns the encoder map, initializing it lazily on first access.
461      *
462      * @return an immutable map of encoder id to encoder
463      */
464     protected Map<String, PasswordEncoder> getEncoders() {
465         Map<String, PasswordEncoder> local = encoders;
466         if (local == null) {
467             synchronized (this) {
468                 local = encoders;
469                 if (local == null) {
470                     final Map<String, PasswordEncoder> map = new LinkedHashMap<>();
471                     map.put(ID_BCRYPT, new BcryptPasswordEncoder());
472                     local = Collections.unmodifiableMap(map);
473                     encoders = local;
474                 }
475             }
476         }
477         return local;
478     }
479 
480     /**
481      * Internal encoder abstraction. Kept package-private/inner to avoid
482      * leaking public API surface.
483      */
484     protected interface PasswordEncoder {
485         /**
486          * Encodes the raw password.
487          *
488          * @param rawPassword the plain-text password
489          * @return the encoded hash (without any <code>{id}</code> prefix)
490          */
491         String encode(String rawPassword);
492 
493         /**
494          * Verifies the raw password against the encoded hash.
495          *
496          * @param rawPassword the plain-text password
497          * @param encodedPassword the encoded hash (without <code>{id}</code> prefix)
498          * @return {@code true} if matched
499          */
500         boolean matches(String rawPassword, String encodedPassword);
501     }
502 
503     /**
504      * BCrypt encoder backed by the vendored jBCrypt implementation.
505      */
506     protected class BcryptPasswordEncoder implements PasswordEncoder {
507 
508         /**
509          * Default constructor.
510          */
511         protected BcryptPasswordEncoder() {
512             // no-op
513         }
514 
515         @Override
516         public String encode(final String rawPassword) {
517             return BCrypt.hashpw(rawPassword, BCrypt.gensalt(resolveBcryptCost()));
518         }
519 
520         @Override
521         public boolean matches(final String rawPassword, final String encodedPassword) {
522             if (encodedPassword == null || encodedPassword.isEmpty()) {
523                 return false;
524             }
525             try {
526                 return BCrypt.checkpw(rawPassword, encodedPassword);
527             } catch (final IllegalArgumentException e) {
528                 // Malformed stored hash.
529                 if (logger.isDebugEnabled()) {
530                     logger.debug("malformed bcrypt hash", e);
531                 }
532                 return false;
533             }
534         }
535 
536     }
537 }