001/**
002 * Copyright (C) 2006-2024 Talend Inc. - www.talend.com
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.talend.sdk.component.maven;
017
018import static java.util.Optional.ofNullable;
019
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.IOException;
023import java.io.InputStream;
024import java.nio.charset.StandardCharsets;
025import java.security.MessageDigest;
026import java.util.Base64;
027import java.util.Collections;
028import java.util.List;
029import java.util.Objects;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import javax.crypto.Cipher;
036import javax.crypto.spec.IvParameterSpec;
037import javax.crypto.spec.SecretKeySpec;
038import javax.xml.XMLConstants;
039import javax.xml.parsers.ParserConfigurationException;
040import javax.xml.parsers.SAXParser;
041import javax.xml.parsers.SAXParserFactory;
042
043import org.xml.sax.Attributes;
044import org.xml.sax.SAXException;
045import org.xml.sax.SAXNotRecognizedException;
046import org.xml.sax.SAXNotSupportedException;
047import org.xml.sax.helpers.DefaultHandler;
048
049import lombok.EqualsAndHashCode;
050import lombok.ToString;
051
052@ToString
053@EqualsAndHashCode
054public class MavenDecrypter {
055
056    private static final String M2_HOME = "M2_HOME";
057
058    private static final String MAVEN_HOME = "MAVEN_HOME";
059
060    private static final String USER_HOME = "user.home";
061
062    private static final String FILE_SETTINGS = "settings.xml";
063
064    private static final String FILE_SECURITY = "settings-security.xml";
065
066    private final List<File> settings;
067
068    private final File settingsSecurity;
069
070    public MavenDecrypter() {
071        this(findSettingsFiles(), new File(getM2(), FILE_SECURITY));
072    }
073
074    @Deprecated
075    public MavenDecrypter(final File settings, final File settingsSecurity) {
076        this(Collections.singletonList(settings), settingsSecurity);
077    }
078
079    public MavenDecrypter(final List<File> settings, final File settingsSecurity) {
080        this.settings = settings.stream().filter(File::exists).collect(Collectors.toList());
081        this.settingsSecurity = settingsSecurity;
082    }
083
084    public Server find(final String serverId) {
085        final SAXParser parser = newSaxParser();
086        if (settings.isEmpty()) {
087            throw new IllegalArgumentException(
088                    "No " + settings + " found, ensure your credentials configuration is valid");
089        }
090
091        final String master;
092        if (settingsSecurity.isFile()) {
093            final MvnMasterExtractor extractor = new MvnMasterExtractor();
094            try (final InputStream is = new FileInputStream(settingsSecurity)) {
095                parser.parse(is, extractor);
096            } catch (final IOException | SAXException e) {
097                throw new IllegalArgumentException(e);
098            }
099            master = extractor.current == null ? null : extractor.current.toString().trim();
100        } else {
101            master = null;
102        }
103
104        final MvnServerExtractor extractor = new MvnServerExtractor(master, serverId);
105        for (final File file : settings) {
106            try (final InputStream is = new FileInputStream(file)) {
107                parser.parse(is, extractor);
108            } catch (final IOException | SAXException e) {
109                throw new IllegalArgumentException(e);
110            }
111            if (extractor.server != null) {
112                return extractor.server;
113            }
114        }
115
116        throw new IllegalArgumentException("Didn't find " + serverId + " in " + settings);
117    }
118
119    private static SAXParser newSaxParser() {
120        final SAXParserFactory factory = SAXParserFactory.newInstance();
121        factory.setNamespaceAware(false);
122        factory.setValidating(false);
123        try {
124            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
125            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, Boolean.TRUE);
126        } catch (final ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException ex) {
127            // ignore
128        }
129
130        final SAXParser parser;
131        try {
132            parser = factory.newSAXParser();
133        } catch (final ParserConfigurationException | SAXException e) {
134            throw new IllegalStateException(e);
135        }
136
137        try {
138            parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
139            parser.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
140        } catch (final SAXNotRecognizedException | SAXNotSupportedException e) {
141            // ignore
142        }
143
144        return parser;
145    }
146
147    private static File getM2() {
148        return ofNullable(System.getProperty("talend.maven.decrypter.m2.location"))
149                .map(File::new)
150                .orElseGet(() -> new File(System.getProperty(USER_HOME), ".m2"));
151    }
152
153    private static List<File> findSettingsFiles() {
154        return Stream.of(
155                new File(getM2(), FILE_SETTINGS),
156                findMavenHome(M2_HOME),
157                findMavenHome(MAVEN_HOME))
158                .filter(Objects::nonNull)
159                .collect(Collectors.toList());
160    }
161
162    private static File findMavenHome(final String mavenHome) {
163        return ofNullable(System.getenv(mavenHome))
164                .map(File::new)
165                .map(it -> new File(it, "conf/" + FILE_SETTINGS))
166                .orElse(null);
167    }
168
169    public static void main(final String[] args) {
170        System.out.println(new MavenDecrypter().find(args[0]));
171    }
172
173    private static class MvnServerExtractor extends DefaultHandler {
174
175        private static final Pattern ENCRYPTED_PATTERN = Pattern.compile(".*?[^\\\\]?\\{(.*?[^\\\\])\\}.*");
176
177        private final String passphrase;
178
179        private final String serverId;
180
181        private Server server;
182
183        private String encryptedPassword;
184
185        private boolean done;
186
187        private StringBuilder current;
188
189        private MvnServerExtractor(final String passphrase, final String serverId) {
190            this.passphrase = doDecrypt(passphrase, "settings.security");
191            this.serverId = serverId;
192        }
193
194        @Override
195        public void startElement(final String uri, final String localName, final String qName,
196                final Attributes attributes) {
197            if ("server".equalsIgnoreCase(qName)) {
198                if (!done) {
199                    server = new Server();
200                }
201            } else if (server != null) {
202                current = new StringBuilder();
203            }
204        }
205
206        @Override
207        public void characters(final char[] ch, final int start, final int length) {
208            if (current != null) {
209                current.append(new String(ch, start, length));
210            }
211        }
212
213        @Override
214        public void endElement(final String uri, final String localName, final String qName) {
215            if (done) {
216                // decrypt password only when the server is found
217                server.setPassword(doDecrypt(encryptedPassword, passphrase));
218                return;
219            }
220            if ("server".equalsIgnoreCase(qName)) {
221                if (server.getId().equals(serverId)) {
222                    done = true;
223                } else if (!done) {
224                    server = null;
225                    encryptedPassword = null;
226                }
227            } else if (server != null && current != null) {
228                switch (qName) {
229                case "id":
230                    server.setId(current.toString());
231                    break;
232                case "username":
233                    try {
234                        server.setUsername(doDecrypt(current.toString(), passphrase));
235                    } catch (final RuntimeException re) {
236                        server.setUsername(current.toString());
237                    }
238                    break;
239                case "password":
240                    encryptedPassword = current.toString();
241                    break;
242                default:
243                }
244                current = null;
245            }
246        }
247
248        private String doDecrypt(final String value, final String pwd) {
249            if (value == null) {
250                return null;
251            }
252
253            final Matcher matcher = ENCRYPTED_PATTERN.matcher(value);
254            if (!matcher.matches() && !matcher.find()) {
255                return value; // not encrypted, just use it
256            }
257
258            final String bare = matcher.group(1);
259            if (value.startsWith("${env.")) {
260                final String key = bare.substring("env.".length());
261                return ofNullable(System.getenv(key)).orElseGet(() -> System.getProperty(bare));
262            }
263            if (value.startsWith("${")) { // all is system prop, no interpolation yet
264                return System.getProperty(bare);
265            }
266
267            if (pwd == null || pwd.isEmpty()) {
268                throw new IllegalArgumentException("Master password can't be null or empty.");
269            }
270
271            if (bare.contains("[") && bare.contains("]") && bare.contains("type=")) {
272                throw new IllegalArgumentException("Unsupported encryption for " + value);
273            }
274
275            final byte[] allEncryptedBytes = Base64.getMimeDecoder().decode(bare);
276            final int totalLen = allEncryptedBytes.length;
277            final byte[] salt = new byte[8];
278            System.arraycopy(allEncryptedBytes, 0, salt, 0, 8);
279            final byte padLen = allEncryptedBytes[8];
280            final byte[] encryptedBytes = new byte[totalLen - 8 - 1 - padLen];
281            System.arraycopy(allEncryptedBytes, 8 + 1, encryptedBytes, 0, encryptedBytes.length);
282
283            try {
284                final MessageDigest digest = MessageDigest.getInstance("SHA-256");
285                byte[] keyAndIv = new byte[16 * 2];
286                byte[] result;
287                int currentPos = 0;
288
289                while (currentPos < keyAndIv.length) {
290                    digest.update(pwd.getBytes(StandardCharsets.UTF_8));
291
292                    digest.update(salt, 0, 8);
293                    result = digest.digest();
294
295                    final int stillNeed = keyAndIv.length - currentPos;
296                    if (result.length > stillNeed) {
297                        final byte[] b = new byte[stillNeed];
298                        System.arraycopy(result, 0, b, 0, b.length);
299                        result = b;
300                    }
301
302                    System.arraycopy(result, 0, keyAndIv, currentPos, result.length);
303
304                    currentPos += result.length;
305                    if (currentPos < keyAndIv.length) {
306                        digest.reset();
307                        digest.update(result);
308                    }
309                }
310
311                final byte[] key = new byte[16];
312                final byte[] iv = new byte[16];
313                System.arraycopy(keyAndIv, 0, key, 0, key.length);
314                System.arraycopy(keyAndIv, key.length, iv, 0, iv.length);
315
316                final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
317                cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
318
319                final byte[] clearBytes = cipher.doFinal(encryptedBytes);
320                return new String(clearBytes, StandardCharsets.UTF_8);
321            } catch (final Exception e) {
322                throw new IllegalStateException(e);
323            }
324        }
325    }
326
327    private static class MvnMasterExtractor extends DefaultHandler {
328
329        private StringBuilder current;
330
331        @Override
332        public void startElement(final String uri, final String localName, final String qName,
333                final Attributes attributes) {
334            if ("master".equalsIgnoreCase(qName)) {
335                current = new StringBuilder();
336            }
337        }
338
339        @Override
340        public void characters(final char[] ch, final int start, final int length) {
341            if (current != null) {
342                current.append(new String(ch, start, length));
343            }
344        }
345    }
346}