001/* 002 * Copyright 2022 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.core.impl.hash; 017 018import java.io.File; 019import java.io.FileInputStream; 020import java.io.FileOutputStream; 021import java.io.IOException; 022import java.security.SecureRandom; 023import java.util.Base64; 024 025import org.apache.commons.io.IOUtils; 026import org.bouncycastle.crypto.generators.Argon2BytesGenerator; 027import org.bouncycastle.crypto.params.Argon2Parameters; 028import org.slf4j.Logger; 029import org.slf4j.LoggerFactory; 030 031import org.ametys.runtime.config.Config; 032import org.ametys.runtime.util.AmetysHomeHelper; 033 034/** 035 * Password encoder using Argon2 hashing algorithm. 036 */ 037public class Argon2PasswordEncoder 038{ 039 /** Argon2PasswordEncoder is based on Argon2PasswordEncoder from spring framework crypto library, and add some pepper */ 040 041 /** Avalon Role */ 042 public static final String ROLE = Argon2PasswordEncoder.class.getName(); 043 044 // Logger for traces 045 private static Logger __logger = LoggerFactory.getLogger(Config.class); 046 047 private static final String __PEPPER_FILE_PREFIX = "auth/pepper_"; 048 049 private static final int __SALT_LENGTH = 16; 050 private static final int __PEPPER_LENGTH = 16; 051 private static final int __HASH_LENGTH = 32; 052 private static final int __PARALLELISM = 1; 053 private static final int __MEMORY = 1 << 13; 054 private static final int __ITERATIONS = 5; 055 056 private static final Base64.Encoder __B64ENCODER = Base64.getEncoder().withoutPadding(); 057 private static final Base64.Decoder __B64DECODER = Base64.getDecoder(); 058 059 private byte[] _pepper; 060 061 /** 062 * Constructor for Argon2PasswordEncoder 063 * @param pepper the pepper used by the Argon2PasswordEncoder 064 */ 065 public Argon2PasswordEncoder(byte[] pepper) 066 { 067 _pepper = pepper; 068 } 069 070 /** 071 * Get a pepper for a given id and generate it if it does not already exists 072 * @param id the id of the pepper 073 * @return the value of the paper 074 * @throws IOException if I/O error occurred. 075 */ 076 public static byte[] getOrCreatePepper(String id) throws IOException 077 { 078 File pepperFile = new File(AmetysHomeHelper.getAmetysHomeData(), __PEPPER_FILE_PREFIX + id); 079 080 if (!pepperFile.exists()) 081 { 082 if (!pepperFile.getParentFile().exists()) 083 { 084 pepperFile.getParentFile().mkdirs(); 085 } 086 087 pepperFile.createNewFile(); 088 089 byte[] pepperByes = generateBytes(__PEPPER_LENGTH); 090 try (FileOutputStream fileOutputStream = new FileOutputStream(pepperFile)) 091 { 092 IOUtils.write(pepperByes, fileOutputStream); 093 } 094 } 095 096 try (FileInputStream inputStream = new FileInputStream(pepperFile)) 097 { 098 return IOUtils.toByteArray(inputStream); 099 } 100 } 101 102 /** 103 * Encode the raw password with argon2id hash algorithm. 104 * @param rawPassword the raw password 105 * @return the hash 106 */ 107 public String encode(CharSequence rawPassword) 108 { 109 byte[] hash = new byte[__HASH_LENGTH]; 110 Argon2Parameters params = new Argon2Parameters 111 .Builder(Argon2Parameters.ARGON2_id) 112 .withSalt(generateBytes(__SALT_LENGTH)) 113 .withParallelism(__PARALLELISM) 114 .withMemoryAsKB(__MEMORY) 115 .withIterations(__ITERATIONS) 116 .withSecret(this._pepper) 117 .build(); 118 119 Argon2BytesGenerator generator = new Argon2BytesGenerator(); 120 generator.init(params); 121 generator.generateBytes(rawPassword.toString().toCharArray(), hash); 122 return encode(hash, params); 123 } 124 125 /** 126 * Verify the encoded password obtained from storage matches the submitted raw 127 * password after it too is encoded. Returns true if the passwords match, false if 128 * they do not. The stored password itself is never decoded. 129 * @param rawPassword the raw password to encode and match 130 * @param encodedPassword the encoded password from storage to compare with 131 * @return true if the raw password, after encoding, matches the encoded password from 132 * storage 133 */ 134 public boolean matches(CharSequence rawPassword, String encodedPassword) 135 { 136 if (encodedPassword == null) 137 { 138 __logger.warn("password hash is null"); 139 return false; 140 } 141 142 Argon2Hash decoded; 143 try 144 { 145 decoded = decode(encodedPassword); 146 } 147 catch (IllegalArgumentException ex) 148 { 149 __logger.warn("Malformed password hash", ex); 150 return false; 151 } 152 153 byte[] hashBytes = new byte[decoded.getHash().length]; 154 Argon2BytesGenerator generator = new Argon2BytesGenerator(); 155 Argon2Parameters decodedParams = decoded.getParameters(); 156 157 Argon2Parameters params = new Argon2Parameters 158 .Builder(Argon2Parameters.ARGON2_id) 159 .withSalt(decodedParams.getSalt()) 160 .withParallelism(decodedParams.getLanes()) 161 .withMemoryAsKB(decodedParams.getMemory()) 162 .withIterations(decodedParams.getIterations()) 163 .withSecret(this._pepper) 164 .build(); 165 166 generator.init(params); 167 generator.generateBytes(rawPassword.toString().toCharArray(), hashBytes); 168 return constantTimeArrayEquals(decoded.getHash(), hashBytes); 169 } 170 171 private boolean constantTimeArrayEquals(byte[] expected, byte[] actual) 172 { 173 if (expected.length != actual.length) 174 { 175 return false; 176 } 177 178 int result = 0; 179 for (int i = 0; i < expected.length; i++) 180 { 181 result |= expected[i] ^ actual[i]; 182 } 183 184 return result == 0; 185 } 186 187 private static byte[] generateBytes(int keyLength) 188 { 189 byte[] bytes = new byte[keyLength]; 190 SecureRandom random = new SecureRandom(); 191 random.nextBytes(bytes); 192 return bytes; 193 } 194 195 /** 196 * Encodes a raw Argon2-hash and its parameters into the standard Argon2-hash-string 197 * as specified in the reference implementation 198 * (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244): 199 * 200 * {@code $argon2<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>} 201 * 202 * where {@code <T>} is either 'd', 'id', or 'i', {@code <num>} is a decimal integer 203 * (positive, fits in an 'unsigned long'), and {@code <bin>} is Base64-encoded data 204 * (no '=' padding characters, no newline or whitespace). 205 * 206 * The last two binary chunks (encoded in Base64) are, in that order, the salt and the 207 * output. If no salt has been used, the salt will be omitted. 208 * @param hash the raw Argon2 hash in binary format 209 * @param parameters the Argon2 parameters that were used to create the hash 210 * @return the encoded Argon2-hash-string as described above 211 * @throws IllegalArgumentException if the Argon2Parameters are invalid 212 */ 213 private String encode(byte[] hash, Argon2Parameters parameters) throws IllegalArgumentException 214 { 215 StringBuilder stringBuilder = new StringBuilder(); 216 switch (parameters.getType()) 217 { 218 case Argon2Parameters.ARGON2_d: 219 stringBuilder.append("$argon2d"); 220 break; 221 case Argon2Parameters.ARGON2_i: 222 stringBuilder.append("$argon2i"); 223 break; 224 case Argon2Parameters.ARGON2_id: 225 stringBuilder.append("$argon2id"); 226 break; 227 default: 228 throw new IllegalArgumentException("Invalid algorithm type: " + parameters.getType()); 229 } 230 231 stringBuilder.append("$v=").append(parameters.getVersion()).append("$m=").append(parameters.getMemory()) 232 .append(",t=").append(parameters.getIterations()).append(",p=").append(parameters.getLanes()); 233 234 if (parameters.getSalt() != null) 235 { 236 stringBuilder.append("$").append(__B64ENCODER.encodeToString(parameters.getSalt())); 237 } 238 239 stringBuilder.append("$").append(__B64ENCODER.encodeToString(hash)); 240 return stringBuilder.toString(); 241 } 242 243 /** 244 * Decodes an Argon2 hash string as specified in the reference implementation 245 * (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244) into 246 * the raw hash and the used parameters. 247 * 248 * The hash has to be formatted as follows: 249 * {@code $argon2<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>} 250 * 251 * where {@code <T>} is either 'd', 'id', or 'i', {@code <num>} is a decimal integer 252 * (positive, fits in an 'unsigned long'), and {@code <bin>} is Base64-encoded data 253 * (no '=' padding characters, no newline or whitespace). 254 * 255 * The last two binary chunks (encoded in Base64) are, in that order, the salt and the 256 * output. Both are required. The binary salt length and the output length must be in 257 * the allowed ranges defined in argon2.h. 258 * @param encodedHash the Argon2 hash string as described above 259 * @return an {@link Argon2Hash} object containing the raw hash and the 260 * {@link Argon2Parameters}. 261 * @throws IllegalArgumentException if the encoded hash is malformed 262 */ 263 private Argon2Hash decode(String encodedHash) throws IllegalArgumentException 264 { 265 Argon2Parameters.Builder paramsBuilder; 266 String[] parts = encodedHash.split("\\$"); 267 if (parts.length < 4) 268 { 269 throw new IllegalArgumentException("Invalid encoded Argon2-hash"); 270 } 271 272 int currentPart = 1; 273 switch (parts[currentPart++]) 274 { 275 case "argon2d": 276 paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_d); 277 break; 278 case "argon2i": 279 paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_i); 280 break; 281 case "argon2id": 282 paramsBuilder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id); 283 break; 284 default: 285 throw new IllegalArgumentException("Invalid algorithm type: " + parts[0]); 286 } 287 288 if (parts[currentPart].startsWith("v=")) 289 { 290 paramsBuilder.withVersion(Integer.parseInt(parts[currentPart].substring(2))); 291 currentPart++; 292 } 293 294 String[] performanceParams = parts[currentPart++].split(","); 295 if (performanceParams.length != 3) 296 { 297 throw new IllegalArgumentException("Amount of performance parameters invalid"); 298 } 299 300 if (!performanceParams[0].startsWith("m=")) 301 { 302 throw new IllegalArgumentException("Invalid memory parameter"); 303 } 304 305 paramsBuilder.withMemoryAsKB(Integer.parseInt(performanceParams[0].substring(2))); 306 307 if (!performanceParams[1].startsWith("t=")) 308 { 309 throw new IllegalArgumentException("Invalid iterations parameter"); 310 } 311 312 paramsBuilder.withIterations(Integer.parseInt(performanceParams[1].substring(2))); 313 314 if (!performanceParams[2].startsWith("p=")) 315 { 316 throw new IllegalArgumentException("Invalid parallelity parameter"); 317 } 318 319 paramsBuilder.withParallelism(Integer.parseInt(performanceParams[2].substring(2))); 320 paramsBuilder.withSalt(__B64DECODER.decode(parts[currentPart++])); 321 return new Argon2Hash(__B64DECODER.decode(parts[currentPart]), paramsBuilder.build()); 322 } 323 324 /** 325 * Object representing Argon hash, with the hash itself and the parameters 326 */ 327 private static class Argon2Hash 328 { 329 private byte[] _hash; 330 331 private Argon2Parameters _parameters; 332 333 Argon2Hash(byte[] hash, Argon2Parameters parameters) 334 { 335 _hash = hash.clone(); 336 _parameters = parameters; 337 } 338 339 /** 340 * get the hash 341 * @return the hash 342 */ 343 public byte[] getHash() 344 { 345 return _hash.clone(); 346 } 347 348 /** 349 * get the parameters 350 * @return the parameters 351 */ 352 public Argon2Parameters getParameters() 353 { 354 return _parameters; 355 } 356 } 357}