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}