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 }