2 * Copyright (C) 2018, 2019 Thomas Wolf <thomas.wolf@paranor.ch> and others
4 * This program and the accompanying materials are made available under the
5 * terms of the Eclipse Distribution License v. 1.0 which is available at
6 * https://www.eclipse.org/org/documents/edl-v10.php.
8 * SPDX-License-Identifier: BSD-3-Clause
10 package org.eclipse.jgit.internal.transport.sshd;
12 import static java.nio.charset.StandardCharsets.UTF_8;
13 import static java.text.MessageFormat.format;
15 import java.io.BufferedReader;
16 import java.io.BufferedWriter;
17 import java.io.FileNotFoundException;
18 import java.io.IOException;
19 import java.io.OutputStreamWriter;
20 import java.net.InetSocketAddress;
21 import java.net.SocketAddress;
22 import java.nio.file.Files;
23 import java.nio.file.InvalidPathException;
24 import java.nio.file.Path;
25 import java.nio.file.Paths;
26 import java.security.GeneralSecurityException;
27 import java.security.PublicKey;
28 import java.security.SecureRandom;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.Collection;
32 import java.util.Collections;
33 import java.util.LinkedList;
34 import java.util.List;
36 import java.util.TreeSet;
37 import java.util.concurrent.ConcurrentHashMap;
38 import java.util.function.Supplier;
40 import org.apache.sshd.client.config.hosts.HostPatternsHolder;
41 import org.apache.sshd.client.config.hosts.KnownHostDigest;
42 import org.apache.sshd.client.config.hosts.KnownHostEntry;
43 import org.apache.sshd.client.config.hosts.KnownHostHashValue;
44 import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair;
45 import org.apache.sshd.client.session.ClientSession;
46 import org.apache.sshd.common.NamedFactory;
47 import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
48 import org.apache.sshd.common.config.keys.KeyUtils;
49 import org.apache.sshd.common.config.keys.PublicKeyEntry;
50 import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
51 import org.apache.sshd.common.digest.BuiltinDigests;
52 import org.apache.sshd.common.mac.Mac;
53 import org.apache.sshd.common.util.io.ModifiableFileWatcher;
54 import org.apache.sshd.common.util.net.SshdSocketAddress;
55 import org.eclipse.jgit.annotations.NonNull;
56 import org.eclipse.jgit.internal.storage.file.LockFile;
57 import org.eclipse.jgit.transport.CredentialItem;
58 import org.eclipse.jgit.transport.CredentialsProvider;
59 import org.eclipse.jgit.transport.URIish;
60 import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
65 * A sever host key verifier that honors the {@code StrictHostKeyChecking} and
66 * {@code UserKnownHostsFile} values from the ssh configuration.
68 * The verifier can be given default known_hosts files in the constructor, which
69 * will be used if the ssh config does not specify a {@code UserKnownHostsFile}.
70 * If the ssh config <em>does</em> set {@code UserKnownHostsFile}, the verifier
71 * uses the given files in the order given. Non-existing or unreadable files are
74 * {@code StrictHostKeyChecking} accepts the following values:
78 * <dd>Ask the user whether new or changed keys shall be accepted and be added
79 * to the known_hosts file.</dd>
81 * <dd>Accept only keys listed in the known_hosts file.</dd>
83 * <dd>Silently accept all new or changed keys, add new keys to the known_hosts
86 * <dd>Silently accept keys for new hosts and add them to the known_hosts
90 * If {@code StrictHostKeyChecking} is not set, or set to any other value, the
91 * default value <b>ask</b> is active.
94 * This implementation relies on the {@link ClientSession} being a
95 * {@link JGitClientSession}. By default Apache MINA sshd does not forward the
96 * config file host entry to the session, so it would be unknown here which
97 * entry it was and what setting of {@code StrictHostKeyChecking} should be
98 * used. If used with some other session type, the implementation assumes
102 * Asking the user is done via a {@link CredentialsProvider} obtained from the
103 * session. If none is set, the implementation falls back to strict host key
104 * checking ("<b>yes</b>").
107 * Note that adding a key to the known hosts file may create the file. You can
108 * specify in the constructor whether the user shall be asked about that, too.
109 * If the user declines updating the file, but the key was otherwise
110 * accepted (user confirmed for "<b>ask</b>", or "no" or "accept-new" are
111 * active), the key is accepted for this session only.
114 * If several known hosts files are specified, a new key is always added to the
115 * first file (even if it doesn't exist yet; see the note about file creation
119 * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
122 public class OpenSshServerKeyDatabase
123 implements ServerKeyDatabase {
125 // TODO: GlobalKnownHostsFile? May need some kind of LRU caching; these
126 // files may be large!
128 private static final Logger LOG = LoggerFactory
129 .getLogger(OpenSshServerKeyDatabase.class);
131 /** Can be used to mark revoked known host lines. */
132 private static final String MARKER_REVOKED = "revoked"; //$NON-NLS-1$
134 private final boolean askAboutNewFile;
136 private final Map<Path, HostKeyFile> knownHostsFiles = new ConcurrentHashMap<>();
138 private final List<HostKeyFile> defaultFiles = new ArrayList<>();
141 * Creates a new {@link OpenSshServerKeyDatabase}.
143 * @param askAboutNewFile
144 * whether to ask the user, if possible, about creating a new
145 * non-existing known_hosts file
146 * @param defaultFiles
147 * typically ~/.ssh/known_hosts and ~/.ssh/known_hosts2. May be
148 * empty or {@code null}, in which case no default files are
149 * installed. The files need not exist.
151 public OpenSshServerKeyDatabase(boolean askAboutNewFile,
152 List<Path> defaultFiles) {
153 if (defaultFiles != null) {
154 for (Path file : defaultFiles) {
155 HostKeyFile newFile = new HostKeyFile(file);
156 knownHostsFiles.put(file, newFile);
157 this.defaultFiles.add(newFile);
160 this.askAboutNewFile = askAboutNewFile;
163 private List<HostKeyFile> getFilesToUse(@NonNull Configuration config) {
164 List<HostKeyFile> filesToUse = defaultFiles;
165 List<HostKeyFile> userFiles = addUserHostKeyFiles(
166 config.getUserKnownHostsFiles());
167 if (!userFiles.isEmpty()) {
168 filesToUse = userFiles;
174 public List<PublicKey> lookup(@NonNull String connectAddress,
175 @NonNull InetSocketAddress remoteAddress,
176 @NonNull Configuration config) {
177 List<HostKeyFile> filesToUse = getFilesToUse(config);
178 List<PublicKey> result = new ArrayList<>();
179 Collection<SshdSocketAddress> candidates = getCandidates(
180 connectAddress, remoteAddress);
181 for (HostKeyFile file : filesToUse) {
182 for (HostEntryPair current : file.get()) {
183 KnownHostEntry entry = current.getHostEntry();
184 for (SshdSocketAddress host : candidates) {
185 if (entry.isHostMatch(host.getHostName(), host.getPort())) {
186 result.add(current.getServerKey());
196 public boolean accept(@NonNull String connectAddress,
197 @NonNull InetSocketAddress remoteAddress,
198 @NonNull PublicKey serverKey,
199 @NonNull Configuration config, CredentialsProvider provider) {
200 List<HostKeyFile> filesToUse = getFilesToUse(config);
201 AskUser ask = new AskUser(config, provider);
202 HostEntryPair[] modified = { null };
204 Collection<SshdSocketAddress> candidates = getCandidates(connectAddress,
206 for (HostKeyFile file : filesToUse) {
208 if (find(candidates, serverKey, file.get(), modified)) {
211 } catch (RevokedKeyException e) {
212 ask.revokedKey(remoteAddress, serverKey, file.getPath());
215 if (path == null && modified[0] != null) {
216 // Remember the file in which we might need to update the
218 path = file.getPath();
221 if (modified[0] != null) {
222 // We found an entry, but with a different key
223 AskUser.ModifiedKeyHandling toDo = ask.acceptModifiedServerKey(
224 remoteAddress, modified[0].getServerKey(),
226 if (toDo == AskUser.ModifiedKeyHandling.ALLOW_AND_STORE) {
228 updateModifiedServerKey(serverKey, modified[0], path);
229 knownHostsFiles.get(path).resetReloadAttributes();
230 } catch (IOException e) {
231 LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate,
235 if (toDo == AskUser.ModifiedKeyHandling.DENY) {
238 // TODO: OpenSsh disables password and keyboard-interactive
239 // authentication in this case. Also agent and local port forwarding
240 // are switched off. (Plus a few other things such as X11 forwarding
241 // that are of no interest to a git client.)
243 } else if (ask.acceptUnknownKey(remoteAddress, serverKey)) {
244 if (!filesToUse.isEmpty()) {
245 HostKeyFile toUpdate = filesToUse.get(0);
246 path = toUpdate.getPath();
248 if (Files.exists(path) || !askAboutNewFile
249 || ask.createNewFile(path)) {
250 updateKnownHostsFile(candidates, serverKey, path,
252 toUpdate.resetReloadAttributes();
254 } catch (Exception e) {
255 LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate,
264 private static class RevokedKeyException extends Exception {
265 private static final long serialVersionUID = 1L;
268 private boolean find(Collection<SshdSocketAddress> candidates,
269 PublicKey serverKey, List<HostEntryPair> entries,
270 HostEntryPair[] modified) throws RevokedKeyException {
271 for (HostEntryPair current : entries) {
272 KnownHostEntry entry = current.getHostEntry();
273 for (SshdSocketAddress host : candidates) {
274 if (entry.isHostMatch(host.getHostName(), host.getPort())) {
275 boolean isRevoked = MARKER_REVOKED
276 .equals(entry.getMarker());
277 if (KeyUtils.compareKeys(serverKey,
278 current.getServerKey())) {
281 throw new RevokedKeyException();
285 } else if (!isRevoked) {
286 // Server sent a different key
287 modified[0] = current;
288 // Keep going -- maybe there's another entry for this
297 private List<HostKeyFile> addUserHostKeyFiles(List<String> fileNames) {
298 if (fileNames == null || fileNames.isEmpty()) {
299 return Collections.emptyList();
301 List<HostKeyFile> userFiles = new ArrayList<>();
302 for (String name : fileNames) {
304 Path path = Paths.get(name);
305 HostKeyFile file = knownHostsFiles.computeIfAbsent(path,
306 p -> new HostKeyFile(path));
308 } catch (InvalidPathException e) {
309 LOG.warn(format(SshdText.get().knownHostsInvalidPath,
316 private void updateKnownHostsFile(Collection<SshdSocketAddress> candidates,
317 PublicKey serverKey, Path path, Configuration config)
319 String newEntry = createHostKeyLine(candidates, serverKey, config);
320 if (newEntry == null) {
323 LockFile lock = new LockFile(path.toFile());
324 if (lock.lockForAppend()) {
326 try (BufferedWriter writer = new BufferedWriter(
327 new OutputStreamWriter(lock.getOutputStream(),
330 writer.write(newEntry);
334 } catch (IOException e) {
339 LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
344 private void updateModifiedServerKey(PublicKey serverKey,
345 HostEntryPair entry, Path path)
347 KnownHostEntry hostEntry = entry.getHostEntry();
348 String oldLine = hostEntry.getConfigLine();
349 String newLine = updateHostKeyLine(oldLine, serverKey);
350 if (newLine == null || newLine.isEmpty()) {
353 if (oldLine == null || oldLine.isEmpty() || newLine.equals(oldLine)) {
357 LockFile lock = new LockFile(path.toFile());
360 try (BufferedWriter writer = new BufferedWriter(
361 new OutputStreamWriter(lock.getOutputStream(), UTF_8));
362 BufferedReader reader = Files.newBufferedReader(path,
364 boolean done = false;
366 while ((line = reader.readLine()) != null) {
367 String toWrite = line;
369 int pos = line.indexOf('#');
370 String toTest = pos < 0 ? line
371 : line.substring(0, pos);
372 if (toTest.trim().equals(oldLine)) {
377 writer.write(toWrite);
382 } catch (IOException e) {
387 LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
392 private static class AskUser {
394 public enum ModifiedKeyHandling {
395 DENY, ALLOW, ALLOW_AND_STORE
402 private final @NonNull Configuration config;
404 private final CredentialsProvider provider;
406 public AskUser(@NonNull Configuration config,
407 CredentialsProvider provider) {
408 this.config = config;
409 this.provider = provider;
412 private static boolean askUser(CredentialsProvider provider, URIish uri,
413 String prompt, String... messages) {
414 List<CredentialItem> items = new ArrayList<>(messages.length + 1);
415 for (String message : messages) {
416 items.add(new CredentialItem.InformationalMessage(message));
418 if (prompt != null) {
419 CredentialItem.YesNoType answer = new CredentialItem.YesNoType(
422 return provider.get(uri, items) && answer.getValue();
424 return provider.get(uri, items);
427 private Check checkMode(SocketAddress remoteAddress, boolean changed) {
428 if (!(remoteAddress instanceof InetSocketAddress)) {
431 switch (config.getStrictHostKeyChecking()) {
437 return changed ? Check.DENY : Check.ALLOW;
439 return provider == null ? Check.DENY : Check.ASK;
443 public void revokedKey(SocketAddress remoteAddress, PublicKey serverKey,
445 if (provider == null) {
448 InetSocketAddress remote = (InetSocketAddress) remoteAddress;
449 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
451 String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
453 String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey);
454 String keyAlgorithm = serverKey.getAlgorithm();
455 askUser(provider, uri, null, //
456 format(SshdText.get().knownHostsRevokedKeyMsg,
457 remote.getHostString(), path),
458 format(SshdText.get().knownHostsKeyFingerprints,
463 public boolean acceptUnknownKey(SocketAddress remoteAddress,
464 PublicKey serverKey) {
465 Check check = checkMode(remoteAddress, false);
466 if (check != Check.ASK) {
467 return check == Check.ALLOW;
469 InetSocketAddress remote = (InetSocketAddress) remoteAddress;
471 String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
473 String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey);
474 String keyAlgorithm = serverKey.getAlgorithm();
475 String remoteHost = remote.getHostString();
476 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
478 String prompt = SshdText.get().knownHostsUnknownKeyPrompt;
479 return askUser(provider, uri, prompt, //
480 format(SshdText.get().knownHostsUnknownKeyMsg,
482 format(SshdText.get().knownHostsKeyFingerprints,
487 public ModifiedKeyHandling acceptModifiedServerKey(
488 InetSocketAddress remoteAddress, PublicKey expected,
489 PublicKey actual, Path path) {
490 Check check = checkMode(remoteAddress, true);
491 if (check == Check.ALLOW) {
492 // Never auto-store on CHECK.ALLOW
493 return ModifiedKeyHandling.ALLOW;
495 String keyAlgorithm = actual.getAlgorithm();
496 String remoteHost = remoteAddress.getHostString();
497 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
499 List<String> messages = new ArrayList<>();
500 String warning = format(
501 SshdText.get().knownHostsModifiedKeyWarning,
502 keyAlgorithm, expected.getAlgorithm(), remoteHost,
503 KeyUtils.getFingerPrint(BuiltinDigests.md5, expected),
504 KeyUtils.getFingerPrint(BuiltinDigests.sha256, expected),
505 KeyUtils.getFingerPrint(BuiltinDigests.md5, actual),
506 KeyUtils.getFingerPrint(BuiltinDigests.sha256, actual));
507 messages.addAll(Arrays.asList(warning.split("\n"))); //$NON-NLS-1$
509 if (check == Check.DENY) {
510 if (provider != null) {
512 SshdText.get().knownHostsModifiedKeyDenyMsg, path));
513 askUser(provider, uri, null,
514 messages.toArray(new String[0]));
516 return ModifiedKeyHandling.DENY;
518 // ASK -- two questions: procceed? and store?
519 List<CredentialItem> items = new ArrayList<>(messages.size() + 2);
520 for (String message : messages) {
521 items.add(new CredentialItem.InformationalMessage(message));
523 CredentialItem.YesNoType proceed = new CredentialItem.YesNoType(
524 SshdText.get().knownHostsModifiedKeyAcceptPrompt);
525 CredentialItem.YesNoType store = new CredentialItem.YesNoType(
526 SshdText.get().knownHostsModifiedKeyStorePrompt);
529 if (provider.get(uri, items) && proceed.getValue()) {
530 return store.getValue() ? ModifiedKeyHandling.ALLOW_AND_STORE
531 : ModifiedKeyHandling.ALLOW;
533 return ModifiedKeyHandling.DENY;
536 public boolean createNewFile(Path path) {
537 if (provider == null) {
538 // We can't ask, so don't create the file
541 URIish uri = new URIish().setPath(path.toString());
542 return askUser(provider, uri, //
543 format(SshdText.get().knownHostsUserAskCreationPrompt,
545 format(SshdText.get().knownHostsUserAskCreationMsg, path));
549 private static class HostKeyFile extends ModifiableFileWatcher
550 implements Supplier<List<HostEntryPair>> {
552 private List<HostEntryPair> entries = Collections.emptyList();
554 public HostKeyFile(Path path) {
559 public List<HostEntryPair> get() {
560 Path path = getPath();
562 if (checkReloadRequired()) {
563 if (!Files.exists(path)) {
565 resetReloadAttributes();
566 return Collections.emptyList();
568 LockFile lock = new LockFile(path.toFile());
571 entries = reload(getPath());
576 LOG.warn(format(SshdText.get().knownHostsFileLockedRead,
580 } catch (IOException e) {
581 LOG.warn(format(SshdText.get().knownHostsFileReadFailed, path));
583 return Collections.unmodifiableList(entries);
586 private List<HostEntryPair> reload(Path path) throws IOException {
588 List<KnownHostEntry> rawEntries = KnownHostEntryReader
590 updateReloadAttributes();
591 if (rawEntries == null || rawEntries.isEmpty()) {
592 return Collections.emptyList();
594 List<HostEntryPair> newEntries = new LinkedList<>();
595 for (KnownHostEntry entry : rawEntries) {
596 AuthorizedKeyEntry keyPart = entry.getKeyEntry();
597 if (keyPart == null) {
601 PublicKey serverKey = keyPart.resolvePublicKey(null,
602 PublicKeyEntryResolver.IGNORING);
603 if (serverKey == null) {
605 SshdText.get().knownHostsUnknownKeyType,
606 path, entry.getConfigLine()));
608 newEntries.add(new HostEntryPair(entry, serverKey));
610 } catch (GeneralSecurityException e) {
611 LOG.warn(format(SshdText.get().knownHostsInvalidLine,
612 path, entry.getConfigLine()));
616 } catch (FileNotFoundException e) {
617 resetReloadAttributes();
618 return Collections.emptyList();
623 private int parsePort(String s) {
625 return Integer.parseInt(s);
626 } catch (NumberFormatException e) {
631 private SshdSocketAddress toSshdSocketAddress(@NonNull String address) {
634 if (HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == address
636 int end = address.indexOf(
637 HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM);
639 return null; // Invalid
641 host = address.substring(1, end);
642 if (end < address.length() - 1
643 && HostPatternsHolder.PORT_VALUE_DELIMITER == address
645 port = parsePort(address.substring(end + 2));
649 .lastIndexOf(HostPatternsHolder.PORT_VALUE_DELIMITER);
651 port = parsePort(address.substring(i + 1));
652 host = address.substring(0, i);
657 if (port < 0 || port > 65535) {
660 return new SshdSocketAddress(host, port);
663 private Collection<SshdSocketAddress> getCandidates(
664 @NonNull String connectAddress,
665 @NonNull InetSocketAddress remoteAddress) {
666 Collection<SshdSocketAddress> candidates = new TreeSet<>(
667 SshdSocketAddress.BY_HOST_AND_PORT);
668 candidates.add(SshdSocketAddress.toSshdSocketAddress(remoteAddress));
669 SshdSocketAddress address = toSshdSocketAddress(connectAddress);
670 if (address != null) {
671 candidates.add(address);
676 private String createHostKeyLine(Collection<SshdSocketAddress> patterns,
677 PublicKey key, Configuration config) throws Exception {
678 StringBuilder result = new StringBuilder();
679 if (config.getHashKnownHosts()) {
680 // SHA1 is the only algorithm for host name hashing known to OpenSSH
681 // or to Apache MINA sshd.
682 NamedFactory<Mac> digester = KnownHostDigest.SHA1;
683 Mac mac = digester.create();
684 SecureRandom prng = new SecureRandom();
685 byte[] salt = new byte[mac.getDefaultBlockSize()];
686 for (SshdSocketAddress address : patterns) {
687 if (result.length() > 0) {
690 prng.nextBytes(salt);
691 KnownHostHashValue.append(result, digester, salt,
692 KnownHostHashValue.calculateHashValue(
693 address.getHostName(), address.getPort(), mac,
697 for (SshdSocketAddress address : patterns) {
698 if (result.length() > 0) {
701 KnownHostHashValue.appendHostPattern(result,
702 address.getHostName(), address.getPort());
706 PublicKeyEntry.appendPublicKeyEntry(result, key);
707 return result.toString();
710 private String updateHostKeyLine(String line, PublicKey newKey)
712 // Replaces an existing public key by the new key
713 int pos = line.indexOf(' ');
714 if (pos > 0 && line.charAt(0) == KnownHostEntry.MARKER_INDICATOR) {
715 // We're at the end of the marker. Skip ahead to the next blank.
716 pos = line.indexOf(' ', pos + 1);
719 // Don't update if bogus format
722 StringBuilder result = new StringBuilder(line.substring(0, pos + 1));
723 PublicKeyEntry.appendPublicKeyEntry(result, newKey);
724 return result.toString();