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