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}