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