001package ball.spring;
002/*-
003 * ##########################################################################
004 * Reusable Spring Components
005 * $Id: MD5CryptPasswordEncoder.java 5855 2020-04-27 20:01:17Z ball $
006 * $HeadURL: svn+ssh://svn.hcf.dev/var/spool/scm/repository.svn/ball-spring/trunk/src/main/java/ball/spring/MD5CryptPasswordEncoder.java $
007 * %%
008 * Copyright (C) 2018 - 2020 Allen D. Ball
009 * %%
010 * Licensed under the Apache License, Version 2.0 (the "License");
011 * you may not use this file except in compliance with the License.
012 * You may obtain a copy of the License at
013 *
014 *      http://www.apache.org/licenses/LICENSE-2.0
015 *
016 * Unless required by applicable law or agreed to in writing, software
017 * distributed under the License is distributed on an "AS IS" BASIS,
018 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
019 * See the License for the specific language governing permissions and
020 * limitations under the License.
021 * ##########################################################################
022 */
023import java.security.MessageDigest;
024import java.security.NoSuchAlgorithmException;
025import java.util.HashMap;
026import java.util.Random;
027import lombok.NoArgsConstructor;
028import lombok.ToString;
029import lombok.extern.log4j.Log4j2;
030import org.springframework.security.crypto.factory.PasswordEncoderFactories;
031import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
032import org.springframework.security.crypto.password.PasswordEncoder;
033import org.springframework.stereotype.Service;
034
035import static java.nio.charset.StandardCharsets.UTF_8;
036
037/**
038 * Dovecot compatible {@link PasswordEncoder} implementation.  MD5-CRYPT
039 * reference implementation available at
040 * {@link.uri https://github.com/dovecot/core/blob/master/src/auth/password-scheme-md5crypt.c target=newtab password-scheme-md5crypt.c}.
041 *
042 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball}
043 * @version $Revision: 5855 $
044 */
045@Service
046@ToString @Log4j2
047public class MD5CryptPasswordEncoder extends DelegatingPasswordEncoder {
048    private static final String MD5_CRYPT = "MD5-CRYPT";
049    private static final HashMap<String,PasswordEncoder> MAP = new HashMap<>();
050
051    static {
052        MAP.put(MD5_CRYPT, MD5Crypt.INSTANCE);
053        MAP.put("CLEAR", NoCrypt.INSTANCE);
054        MAP.put("CLEARTEXT", NoCrypt.INSTANCE);
055        MAP.put("PLAIN", NoCrypt.INSTANCE);
056        MAP.put("PLAINTEXT", NoCrypt.INSTANCE);
057    }
058
059    private static final Random RANDOM = new Random();
060
061    /**
062     * Sole constructor.
063     */
064    public MD5CryptPasswordEncoder() {
065        super(MD5_CRYPT, MAP);
066
067        setDefaultPasswordEncoderForMatches(PasswordEncoderFactories.createDelegatingPasswordEncoder());
068    }
069
070    @NoArgsConstructor @ToString
071    private static class NoCrypt implements PasswordEncoder {
072        private static final String SALT =
073            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
074        private static final String ITOA64 =
075            "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
076
077        public static final NoCrypt INSTANCE = new NoCrypt();
078
079        @Override
080        public String encode(CharSequence raw) {
081            return raw.toString();
082        }
083
084        @Override
085        public boolean matches(CharSequence raw, String encoded) {
086            return raw.toString().equals(encoded);
087        }
088
089        protected String salt(int length) {
090            StringBuilder buffer = new StringBuilder();
091
092            while (buffer.length() < length) {
093                int index = (int) (RANDOM.nextFloat() * SALT.length());
094
095                buffer.append(SALT.charAt(index));
096            }
097
098            return buffer.toString();
099        }
100
101        protected String itoa64(long value, int size) {
102            StringBuilder buffer = new StringBuilder();
103
104            while (--size >= 0) {
105                buffer.append(ITOA64.charAt((int) (value & 0x3f)));
106
107                value >>>= 6;
108            }
109
110            return buffer.toString();
111        }
112    }
113
114    @NoArgsConstructor @ToString
115    private static class MD5Crypt extends NoCrypt {
116        private static final String MD5 = "md5";
117        private static final String MAGIC = "$1$";
118        private static final int SALT_LENGTH = 8;
119
120        public static final MD5Crypt INSTANCE = new MD5Crypt();
121
122        @Override
123        public String encode(CharSequence raw) {
124            return encode(raw.toString(), salt(SALT_LENGTH));
125        }
126
127        private String encode(String raw, String salt) {
128            if (salt.length() > SALT_LENGTH) {
129                salt = salt.substring(0, SALT_LENGTH);
130            }
131
132            return (MAGIC + salt + "$"
133                    + encode(raw.getBytes(UTF_8), salt.getBytes(UTF_8)));
134        }
135
136        private String encode(byte[] password, byte[] salt) {
137            byte[] bytes = null;
138
139            try {
140                MessageDigest ctx = MessageDigest.getInstance(MD5);
141                MessageDigest ctx1 = MessageDigest.getInstance(MD5);
142
143                ctx.update(password);
144                ctx.update(MAGIC.getBytes(UTF_8));
145                ctx.update(salt);
146
147                ctx1.update(password);
148                ctx1.update(salt);
149                ctx1.update(password);
150                bytes = ctx1.digest();
151
152                for (int i = password.length; i > 0;  i -= 16) {
153                    ctx.update(bytes, 0, (i > 16) ? 16 : i);
154                }
155
156                for (int i = 0; i < bytes.length; i += 1) {
157                    bytes[i] = 0;
158                }
159
160                for (int i = password.length; i != 0; i >>>= 1) {
161                    if ((i & 1) != 0) {
162                        ctx.update(bytes, 0, 1);
163                    } else {
164                        ctx.update(password, 0, 1);
165                    }
166                }
167
168                bytes = ctx.digest();
169
170                for (int i = 0; i < 1000; i += 1) {
171                    ctx1 = MessageDigest.getInstance(MD5);
172
173                    if ((i & 1) != 0) {
174                        ctx1.update(password);
175                    } else {
176                        ctx1.update(bytes, 0, 16);
177                    }
178
179                    if ((i % 3) != 0) {
180                        ctx1.update(salt);
181                    }
182
183                    if ((i % 7) != 0) {
184                        ctx1.update(password);
185                    }
186
187                    if ((i & 1) != 0) {
188                        ctx1.update(bytes, 0, 16);
189                    } else {
190                        ctx1.update(password);
191                    }
192
193                    bytes = ctx1.digest();
194                }
195            } catch (NoSuchAlgorithmException exception) {
196                throw new IllegalStateException(exception);
197            }
198
199            StringBuilder result =
200                new StringBuilder()
201                .append(combine(bytes[0], bytes[6], bytes[12], 4))
202                .append(combine(bytes[1], bytes[7], bytes[13], 4))
203                .append(combine(bytes[2], bytes[8], bytes[14], 4))
204                .append(combine(bytes[3], bytes[9], bytes[15], 4))
205                .append(combine(bytes[4], bytes[10], bytes[5], 4))
206                .append(combine((byte) 0, (byte) 0, bytes[11], 2));
207
208            return result.toString();
209        }
210
211        private String combine(byte b0, byte b1, byte b2, int size) {
212            return itoa64(((((long) b0) & 0xff) << 16)
213                          | ((((long) b1) & 0xff) << 8)
214                          | (((long) b2) & 0xff),
215                          size);
216        }
217
218        @Override
219        public boolean matches(CharSequence raw, String encoded) {
220            String salt = null;
221
222            if (encoded.startsWith(MAGIC)) {
223                salt = encoded.substring(MAGIC.length()).split("[$]")[0];
224            } else {
225                throw new IllegalArgumentException("Invalid format");
226            }
227
228            return encoded.equals(encode(raw.toString(), salt));
229        }
230    }
231}