2 * Copyright (C) 2018, 2021 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.NoSuchFileException;
25 import java.nio.file.Path;
26 import java.nio.file.Paths;
27 import java.security.GeneralSecurityException;
28 import java.security.PublicKey;
29 import java.security.SecureRandom;
30 import java.util.ArrayList;
31 import java.util.Arrays;
32 import java.util.Collection;
33 import java.util.Collections;
34 import java.util.LinkedList;
35 import java.util.List;
37 import java.util.Random;
38 import java.util.TreeSet;
39 import java.util.concurrent.ConcurrentHashMap;
40 import java.util.function.Supplier;
42 import org.apache.sshd.client.config.hosts.HostPatternsHolder;
43 import org.apache.sshd.client.config.hosts.KnownHostDigest;
44 import org.apache.sshd.client.config.hosts.KnownHostEntry;
45 import org.apache.sshd.client.config.hosts.KnownHostHashValue;
46 import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair;
47 import org.apache.sshd.client.session.ClientSession;
48 import org.apache.sshd.common.NamedFactory;
49 import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
50 import org.apache.sshd.common.config.keys.KeyUtils;
51 import org.apache.sshd.common.config.keys.PublicKeyEntry;
52 import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
53 import org.apache.sshd.common.digest.BuiltinDigests;
54 import org.apache.sshd.common.mac.Mac;
55 import org.apache.sshd.common.util.io.ModifiableFileWatcher;
56 import org.apache.sshd.common.util.net.SshdSocketAddress;
57 import org.eclipse.jgit.annotations.NonNull;
58 import org.eclipse.jgit.internal.storage.file.LockFile;
59 import org.eclipse.jgit.transport.CredentialItem;
60 import org.eclipse.jgit.transport.CredentialsProvider;
61 import org.eclipse.jgit.transport.URIish;
62 import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
63 import org.slf4j.Logger;
64 import org.slf4j.LoggerFactory;
67 * A sever host key verifier that honors the {@code StrictHostKeyChecking} and
68 * {@code UserKnownHostsFile} values from the ssh configuration.
70 * The verifier can be given default known_hosts files in the constructor, which
71 * will be used if the ssh config does not specify a {@code UserKnownHostsFile}.
72 * If the ssh config <em>does</em> set {@code UserKnownHostsFile}, the verifier
73 * uses the given files in the order given. Non-existing or unreadable files are
76 * {@code StrictHostKeyChecking} accepts the following values:
80 * <dd>Ask the user whether new or changed keys shall be accepted and be added
81 * to the known_hosts file.</dd>
83 * <dd>Accept only keys listed in the known_hosts file.</dd>
85 * <dd>Silently accept all new or changed keys, add new keys to the known_hosts
88 * <dd>Silently accept keys for new hosts and add them to the known_hosts
92 * If {@code StrictHostKeyChecking} is not set, or set to any other value, the
93 * default value <b>ask</b> is active.
96 * This implementation relies on the {@link ClientSession} being a
97 * {@link JGitClientSession}. By default Apache MINA sshd does not forward the
98 * config file host entry to the session, so it would be unknown here which
99 * entry it was and what setting of {@code StrictHostKeyChecking} should be
100 * used. If used with some other session type, the implementation assumes
104 * Asking the user is done via a {@link CredentialsProvider} obtained from the
105 * session. If none is set, the implementation falls back to strict host key
106 * checking ("<b>yes</b>").
109 * Note that adding a key to the known hosts file may create the file. You can
110 * specify in the constructor whether the user shall be asked about that, too.
111 * If the user declines updating the file, but the key was otherwise
112 * accepted (user confirmed for "<b>ask</b>", or "no" or "accept-new" are
113 * active), the key is accepted for this session only.
116 * If several known hosts files are specified, a new key is always added to the
117 * first file (even if it doesn't exist yet; see the note about file creation
121 * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
124 public class OpenSshServerKeyDatabase
125 implements ServerKeyDatabase {
127 // TODO: GlobalKnownHostsFile? May need some kind of LRU caching; these
128 // files may be large!
130 private static final Logger LOG = LoggerFactory
131 .getLogger(OpenSshServerKeyDatabase.class);
133 /** Can be used to mark revoked known host lines. */
134 private static final String MARKER_REVOKED = "revoked"; //$NON-NLS-1$
136 private final boolean askAboutNewFile;
138 private final Map<Path, HostKeyFile> knownHostsFiles = new ConcurrentHashMap<>();
140 private final List<HostKeyFile> defaultFiles = new ArrayList<>();
145 * Creates a new {@link OpenSshServerKeyDatabase}.
147 * @param askAboutNewFile
148 * whether to ask the user, if possible, about creating a new
149 * non-existing known_hosts file
150 * @param defaultFiles
151 * typically ~/.ssh/known_hosts and ~/.ssh/known_hosts2. May be
152 * empty or {@code null}, in which case no default files are
153 * installed. The files need not exist.
155 public OpenSshServerKeyDatabase(boolean askAboutNewFile,
156 List<Path> defaultFiles) {
157 if (defaultFiles != null) {
158 for (Path file : defaultFiles) {
159 HostKeyFile newFile = new HostKeyFile(file);
160 knownHostsFiles.put(file, newFile);
161 this.defaultFiles.add(newFile);
164 this.askAboutNewFile = askAboutNewFile;
167 private List<HostKeyFile> getFilesToUse(@NonNull Configuration config) {
168 List<HostKeyFile> filesToUse = defaultFiles;
169 List<HostKeyFile> userFiles = addUserHostKeyFiles(
170 config.getUserKnownHostsFiles());
171 if (!userFiles.isEmpty()) {
172 filesToUse = userFiles;
178 public List<PublicKey> lookup(@NonNull String connectAddress,
179 @NonNull InetSocketAddress remoteAddress,
180 @NonNull Configuration config) {
181 List<HostKeyFile> filesToUse = getFilesToUse(config);
182 List<PublicKey> result = new ArrayList<>();
183 Collection<SshdSocketAddress> candidates = getCandidates(
184 connectAddress, remoteAddress);
185 for (HostKeyFile file : filesToUse) {
186 for (HostEntryPair current : file.get()) {
187 KnownHostEntry entry = current.getHostEntry();
188 if (!isRevoked(entry)) {
189 for (SshdSocketAddress host : candidates) {
190 if (entry.isHostMatch(host.getHostName(),
192 result.add(current.getServerKey());
203 public boolean accept(@NonNull String connectAddress,
204 @NonNull InetSocketAddress remoteAddress,
205 @NonNull PublicKey serverKey,
206 @NonNull Configuration config, CredentialsProvider provider) {
207 List<HostKeyFile> filesToUse = getFilesToUse(config);
208 AskUser ask = new AskUser(config, provider);
209 HostEntryPair[] modified = { null };
211 Collection<SshdSocketAddress> candidates = getCandidates(connectAddress,
213 for (HostKeyFile file : filesToUse) {
215 if (find(candidates, serverKey, file.get(), modified)) {
218 } catch (RevokedKeyException e) {
219 ask.revokedKey(remoteAddress, serverKey, file.getPath());
222 if (path == null && modified[0] != null) {
223 // Remember the file in which we might need to update the
225 path = file.getPath();
228 if (modified[0] != null) {
229 // We found an entry, but with a different key
230 AskUser.ModifiedKeyHandling toDo = ask.acceptModifiedServerKey(
231 remoteAddress, modified[0].getServerKey(),
233 if (toDo == AskUser.ModifiedKeyHandling.ALLOW_AND_STORE) {
235 updateModifiedServerKey(serverKey, modified[0], path);
236 knownHostsFiles.get(path).resetReloadAttributes();
237 } catch (IOException e) {
238 LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate,
242 if (toDo == AskUser.ModifiedKeyHandling.DENY) {
245 // TODO: OpenSsh disables password and keyboard-interactive
246 // authentication in this case. Also agent and local port forwarding
247 // are switched off. (Plus a few other things such as X11 forwarding
248 // that are of no interest to a git client.)
250 } else if (ask.acceptUnknownKey(remoteAddress, serverKey)) {
251 if (!filesToUse.isEmpty()) {
252 HostKeyFile toUpdate = filesToUse.get(0);
253 path = toUpdate.getPath();
255 if (Files.exists(path) || !askAboutNewFile
256 || ask.createNewFile(path)) {
257 updateKnownHostsFile(candidates, serverKey, path,
259 toUpdate.resetReloadAttributes();
261 } catch (Exception e) {
262 LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate,
271 private static class RevokedKeyException extends Exception {
272 private static final long serialVersionUID = 1L;
275 private boolean isRevoked(KnownHostEntry entry) {
276 return MARKER_REVOKED.equals(entry.getMarker());
279 private boolean find(Collection<SshdSocketAddress> candidates,
280 PublicKey serverKey, List<HostEntryPair> entries,
281 HostEntryPair[] modified) throws RevokedKeyException {
282 for (HostEntryPair current : entries) {
283 KnownHostEntry entry = current.getHostEntry();
284 for (SshdSocketAddress host : candidates) {
285 if (entry.isHostMatch(host.getHostName(), host.getPort())) {
286 boolean revoked = isRevoked(entry);
287 if (KeyUtils.compareKeys(serverKey,
288 current.getServerKey())) {
291 throw new RevokedKeyException();
295 } else if (!revoked) {
296 // Server sent a different key
297 modified[0] = current;
298 // Keep going -- maybe there's another entry for this
308 private List<HostKeyFile> addUserHostKeyFiles(List<String> fileNames) {
309 if (fileNames == null || fileNames.isEmpty()) {
310 return Collections.emptyList();
312 List<HostKeyFile> userFiles = new ArrayList<>();
313 for (String name : fileNames) {
315 Path path = Paths.get(name);
316 HostKeyFile file = knownHostsFiles.computeIfAbsent(path,
317 p -> new HostKeyFile(path));
319 } catch (InvalidPathException e) {
320 LOG.warn(format(SshdText.get().knownHostsInvalidPath,
327 private void updateKnownHostsFile(Collection<SshdSocketAddress> candidates,
328 PublicKey serverKey, Path path, Configuration config)
330 String newEntry = createHostKeyLine(candidates, serverKey, config);
331 if (newEntry == null) {
334 LockFile lock = new LockFile(path.toFile());
335 if (lock.lockForAppend()) {
337 try (BufferedWriter writer = new BufferedWriter(
338 new OutputStreamWriter(lock.getOutputStream(),
341 writer.write(newEntry);
345 } catch (IOException e) {
350 LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
355 private void updateModifiedServerKey(PublicKey serverKey,
356 HostEntryPair entry, Path path)
358 KnownHostEntry hostEntry = entry.getHostEntry();
359 String oldLine = hostEntry.getConfigLine();
360 if (oldLine == null) {
363 String newLine = updateHostKeyLine(oldLine, serverKey);
364 if (newLine == null || newLine.isEmpty()) {
367 if (oldLine.isEmpty() || newLine.equals(oldLine)) {
371 LockFile lock = new LockFile(path.toFile());
374 try (BufferedWriter writer = new BufferedWriter(
375 new OutputStreamWriter(lock.getOutputStream(), UTF_8));
376 BufferedReader reader = Files.newBufferedReader(path,
378 boolean done = false;
380 while ((line = reader.readLine()) != null) {
381 String toWrite = line;
383 int pos = line.indexOf('#');
384 String toTest = pos < 0 ? line
385 : line.substring(0, pos);
386 if (toTest.trim().equals(oldLine)) {
391 writer.write(toWrite);
396 } catch (IOException e) {
401 LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
406 private static class AskUser {
408 public enum ModifiedKeyHandling {
409 DENY, ALLOW, ALLOW_AND_STORE
416 private final @NonNull Configuration config;
418 private final CredentialsProvider provider;
420 public AskUser(@NonNull Configuration config,
421 CredentialsProvider provider) {
422 this.config = config;
423 this.provider = provider;
426 private static boolean askUser(CredentialsProvider provider, URIish uri,
427 String prompt, String... messages) {
428 List<CredentialItem> items = new ArrayList<>(messages.length + 1);
429 for (String message : messages) {
430 items.add(new CredentialItem.InformationalMessage(message));
432 if (prompt != null) {
433 CredentialItem.YesNoType answer = new CredentialItem.YesNoType(
436 return provider.get(uri, items) && answer.getValue();
438 return provider.get(uri, items);
441 private Check checkMode(SocketAddress remoteAddress, boolean changed) {
442 if (!(remoteAddress instanceof InetSocketAddress)) {
445 switch (config.getStrictHostKeyChecking()) {
451 return changed ? Check.DENY : Check.ALLOW;
453 return provider == null ? Check.DENY : Check.ASK;
457 public void revokedKey(SocketAddress remoteAddress, PublicKey serverKey,
459 if (provider == null) {
462 InetSocketAddress remote = (InetSocketAddress) remoteAddress;
463 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
465 String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
467 String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey);
468 String keyAlgorithm = serverKey.getAlgorithm();
469 askUser(provider, uri, null, //
470 format(SshdText.get().knownHostsRevokedKeyMsg,
471 remote.getHostString(), path),
472 format(SshdText.get().knownHostsKeyFingerprints,
477 public boolean acceptUnknownKey(SocketAddress remoteAddress,
478 PublicKey serverKey) {
479 Check check = checkMode(remoteAddress, false);
480 if (check != Check.ASK) {
481 return check == Check.ALLOW;
483 InetSocketAddress remote = (InetSocketAddress) remoteAddress;
485 String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
487 String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey);
488 String keyAlgorithm = serverKey.getAlgorithm();
489 String remoteHost = remote.getHostString();
490 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
492 String prompt = SshdText.get().knownHostsUnknownKeyPrompt;
493 return askUser(provider, uri, prompt, //
494 format(SshdText.get().knownHostsUnknownKeyMsg,
496 format(SshdText.get().knownHostsKeyFingerprints,
501 public ModifiedKeyHandling acceptModifiedServerKey(
502 InetSocketAddress remoteAddress, PublicKey expected,
503 PublicKey actual, Path path) {
504 Check check = checkMode(remoteAddress, true);
505 if (check == Check.ALLOW) {
506 // Never auto-store on CHECK.ALLOW
507 return ModifiedKeyHandling.ALLOW;
509 String keyAlgorithm = actual.getAlgorithm();
510 String remoteHost = remoteAddress.getHostString();
511 URIish uri = JGitUserInteraction.toURI(config.getUsername(),
513 List<String> messages = new ArrayList<>();
514 String warning = format(
515 SshdText.get().knownHostsModifiedKeyWarning,
516 keyAlgorithm, expected.getAlgorithm(), remoteHost,
517 KeyUtils.getFingerPrint(BuiltinDigests.md5, expected),
518 KeyUtils.getFingerPrint(BuiltinDigests.sha256, expected),
519 KeyUtils.getFingerPrint(BuiltinDigests.md5, actual),
520 KeyUtils.getFingerPrint(BuiltinDigests.sha256, actual));
521 messages.addAll(Arrays.asList(warning.split("\n"))); //$NON-NLS-1$
523 if (check == Check.DENY) {
524 if (provider != null) {
526 SshdText.get().knownHostsModifiedKeyDenyMsg, path));
527 askUser(provider, uri, null,
528 messages.toArray(new String[0]));
530 return ModifiedKeyHandling.DENY;
532 // ASK -- two questions: procceed? and store?
533 List<CredentialItem> items = new ArrayList<>(messages.size() + 2);
534 for (String message : messages) {
535 items.add(new CredentialItem.InformationalMessage(message));
537 CredentialItem.YesNoType proceed = new CredentialItem.YesNoType(
538 SshdText.get().knownHostsModifiedKeyAcceptPrompt);
539 CredentialItem.YesNoType store = new CredentialItem.YesNoType(
540 SshdText.get().knownHostsModifiedKeyStorePrompt);
543 if (provider.get(uri, items) && proceed.getValue()) {
544 return store.getValue() ? ModifiedKeyHandling.ALLOW_AND_STORE
545 : ModifiedKeyHandling.ALLOW;
547 return ModifiedKeyHandling.DENY;
550 public boolean createNewFile(Path path) {
551 if (provider == null) {
552 // We can't ask, so don't create the file
555 URIish uri = new URIish().setPath(path.toString());
556 return askUser(provider, uri, //
557 format(SshdText.get().knownHostsUserAskCreationPrompt,
559 format(SshdText.get().knownHostsUserAskCreationMsg, path));
563 private static class HostKeyFile extends ModifiableFileWatcher
564 implements Supplier<List<HostEntryPair>> {
566 private List<HostEntryPair> entries = Collections.emptyList();
568 public HostKeyFile(Path path) {
573 public List<HostEntryPair> get() {
574 Path path = getPath();
575 synchronized (this) {
577 if (checkReloadRequired()) {
578 entries = reload(getPath());
580 } catch (IOException e) {
581 LOG.warn(format(SshdText.get().knownHostsFileReadFailed,
584 return Collections.unmodifiableList(entries);
588 private List<HostEntryPair> reload(Path path) throws IOException {
590 List<KnownHostEntry> rawEntries = KnownHostEntryReader
592 updateReloadAttributes();
593 if (rawEntries == null || rawEntries.isEmpty()) {
594 return Collections.emptyList();
596 List<HostEntryPair> newEntries = new LinkedList<>();
597 for (KnownHostEntry entry : rawEntries) {
598 AuthorizedKeyEntry keyPart = entry.getKeyEntry();
599 if (keyPart == null) {
603 PublicKey serverKey = keyPart.resolvePublicKey(null,
604 PublicKeyEntryResolver.IGNORING);
605 if (serverKey == null) {
607 SshdText.get().knownHostsUnknownKeyType,
608 path, entry.getConfigLine()));
610 newEntries.add(new HostEntryPair(entry, serverKey));
612 } catch (GeneralSecurityException e) {
613 LOG.warn(format(SshdText.get().knownHostsInvalidLine,
614 path, entry.getConfigLine()));
618 } catch (FileNotFoundException | NoSuchFileException e) {
619 resetReloadAttributes();
620 return Collections.emptyList();
625 private int parsePort(String s) {
627 return Integer.parseInt(s);
628 } catch (NumberFormatException e) {
633 private SshdSocketAddress toSshdSocketAddress(@NonNull String address) {
636 if (HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == address
638 int end = address.indexOf(
639 HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM);
641 return null; // Invalid
643 host = address.substring(1, end);
644 if (end < address.length() - 1
645 && HostPatternsHolder.PORT_VALUE_DELIMITER == address
647 port = parsePort(address.substring(end + 2));
651 .lastIndexOf(HostPatternsHolder.PORT_VALUE_DELIMITER);
653 port = parsePort(address.substring(i + 1));
654 host = address.substring(0, i);
659 if (port < 0 || port > 65535) {
662 return new SshdSocketAddress(host, port);
665 private Collection<SshdSocketAddress> getCandidates(
666 @NonNull String connectAddress,
667 @NonNull InetSocketAddress remoteAddress) {
668 Collection<SshdSocketAddress> candidates = new TreeSet<>(
669 SshdSocketAddress.BY_HOST_AND_PORT);
670 candidates.add(SshdSocketAddress.toSshdSocketAddress(remoteAddress));
671 SshdSocketAddress address = toSshdSocketAddress(connectAddress);
672 if (address != null) {
673 candidates.add(address);
678 private String createHostKeyLine(Collection<SshdSocketAddress> patterns,
679 PublicKey key, Configuration config) throws Exception {
680 StringBuilder result = new StringBuilder();
681 if (config.getHashKnownHosts()) {
682 // SHA1 is the only algorithm for host name hashing known to OpenSSH
683 // or to Apache MINA sshd.
684 NamedFactory<Mac> digester = KnownHostDigest.SHA1;
685 Mac mac = digester.create();
687 prng = new SecureRandom();
689 byte[] salt = new byte[mac.getDefaultBlockSize()];
690 for (SshdSocketAddress address : patterns) {
691 if (result.length() > 0) {
694 prng.nextBytes(salt);
695 KnownHostHashValue.append(result, digester, salt,
696 KnownHostHashValue.calculateHashValue(
697 address.getHostName(), address.getPort(), mac,
701 for (SshdSocketAddress address : patterns) {
702 if (result.length() > 0) {
705 KnownHostHashValue.appendHostPattern(result,
706 address.getHostName(), address.getPort());
710 PublicKeyEntry.appendPublicKeyEntry(result, key);
711 return result.toString();
714 private String updateHostKeyLine(String line, PublicKey newKey)
716 // Replaces an existing public key by the new key
717 int pos = line.indexOf(' ');
718 if (pos > 0 && line.charAt(0) == KnownHostEntry.MARKER_INDICATOR) {
719 // We're at the end of the marker. Skip ahead to the next blank.
720 pos = line.indexOf(' ', pos + 1);
723 // Don't update if bogus format
726 StringBuilder result = new StringBuilder(line.substring(0, pos + 1));
727 PublicKeyEntry.appendPublicKeyEntry(result, newKey);
728 return result.toString();