/* * Copyright (C) 2018, 2019 Thomas Wolf and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.internal.transport.sshd; import static java.nio.charset.StandardCharsets.UTF_8; import static java.text.MessageFormat.format; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStreamWriter; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.PublicKey; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import org.apache.sshd.client.config.hosts.HostPatternsHolder; import org.apache.sshd.client.config.hosts.KnownHostDigest; import org.apache.sshd.client.config.hosts.KnownHostEntry; import org.apache.sshd.client.config.hosts.KnownHostHashValue; import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.NamedFactory; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.digest.BuiltinDigests; import org.apache.sshd.common.mac.Mac; import org.apache.sshd.common.util.io.ModifiableFileWatcher; import org.apache.sshd.common.util.net.SshdSocketAddress; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.internal.storage.file.LockFile; import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.URIish; import org.eclipse.jgit.transport.sshd.ServerKeyDatabase; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A sever host key verifier that honors the {@code StrictHostKeyChecking} and * {@code UserKnownHostsFile} values from the ssh configuration. *

* The verifier can be given default known_hosts files in the constructor, which * will be used if the ssh config does not specify a {@code UserKnownHostsFile}. * If the ssh config does set {@code UserKnownHostsFile}, the verifier * uses the given files in the order given. Non-existing or unreadable files are * ignored. *

* {@code StrictHostKeyChecking} accepts the following values: *

*
*
ask
*
Ask the user whether new or changed keys shall be accepted and be added * to the known_hosts file.
*
yes/true
*
Accept only keys listed in the known_hosts file.
*
no/false
*
Silently accept all new or changed keys, add new keys to the known_hosts * file.
*
accept-new
*
Silently accept keys for new hosts and add them to the known_hosts * file.
*
*

* If {@code StrictHostKeyChecking} is not set, or set to any other value, the * default value ask is active. *

*

* This implementation relies on the {@link ClientSession} being a * {@link JGitClientSession}. By default Apache MINA sshd does not forward the * config file host entry to the session, so it would be unknown here which * entry it was and what setting of {@code StrictHostKeyChecking} should be * used. If used with some other session type, the implementation assumes * "ask". *

*

* Asking the user is done via a {@link CredentialsProvider} obtained from the * session. If none is set, the implementation falls back to strict host key * checking ("yes"). *

*

* Note that adding a key to the known hosts file may create the file. You can * specify in the constructor whether the user shall be asked about that, too. * If the user declines updating the file, but the key was otherwise * accepted (user confirmed for "ask", or "no" or "accept-new" are * active), the key is accepted for this session only. *

*

* If several known hosts files are specified, a new key is always added to the * first file (even if it doesn't exist yet; see the note about file creation * above). *

* * @see man * ssh-config */ public class OpenSshServerKeyDatabase implements ServerKeyDatabase { // TODO: GlobalKnownHostsFile? May need some kind of LRU caching; these // files may be large! private static final Logger LOG = LoggerFactory .getLogger(OpenSshServerKeyDatabase.class); /** Can be used to mark revoked known host lines. */ private static final String MARKER_REVOKED = "revoked"; //$NON-NLS-1$ private final boolean askAboutNewFile; private final Map knownHostsFiles = new ConcurrentHashMap<>(); private final List defaultFiles = new ArrayList<>(); /** * Creates a new {@link OpenSshServerKeyDatabase}. * * @param askAboutNewFile * whether to ask the user, if possible, about creating a new * non-existing known_hosts file * @param defaultFiles * typically ~/.ssh/known_hosts and ~/.ssh/known_hosts2. May be * empty or {@code null}, in which case no default files are * installed. The files need not exist. */ public OpenSshServerKeyDatabase(boolean askAboutNewFile, List defaultFiles) { if (defaultFiles != null) { for (Path file : defaultFiles) { HostKeyFile newFile = new HostKeyFile(file); knownHostsFiles.put(file, newFile); this.defaultFiles.add(newFile); } } this.askAboutNewFile = askAboutNewFile; } private List getFilesToUse(@NonNull Configuration config) { List filesToUse = defaultFiles; List userFiles = addUserHostKeyFiles( config.getUserKnownHostsFiles()); if (!userFiles.isEmpty()) { filesToUse = userFiles; } return filesToUse; } @Override public List lookup(@NonNull String connectAddress, @NonNull InetSocketAddress remoteAddress, @NonNull Configuration config) { List filesToUse = getFilesToUse(config); List result = new ArrayList<>(); Collection candidates = getCandidates( connectAddress, remoteAddress); for (HostKeyFile file : filesToUse) { for (HostEntryPair current : file.get()) { KnownHostEntry entry = current.getHostEntry(); for (SshdSocketAddress host : candidates) { if (entry.isHostMatch(host.getHostName(), host.getPort())) { result.add(current.getServerKey()); break; } } } } return result; } @Override public boolean accept(@NonNull String connectAddress, @NonNull InetSocketAddress remoteAddress, @NonNull PublicKey serverKey, @NonNull Configuration config, CredentialsProvider provider) { List filesToUse = getFilesToUse(config); AskUser ask = new AskUser(config, provider); HostEntryPair[] modified = { null }; Path path = null; Collection candidates = getCandidates(connectAddress, remoteAddress); for (HostKeyFile file : filesToUse) { try { if (find(candidates, serverKey, file.get(), modified)) { return true; } } catch (RevokedKeyException e) { ask.revokedKey(remoteAddress, serverKey, file.getPath()); return false; } if (path == null && modified[0] != null) { // Remember the file in which we might need to update the // entry path = file.getPath(); } } if (modified[0] != null) { // We found an entry, but with a different key AskUser.ModifiedKeyHandling toDo = ask.acceptModifiedServerKey( remoteAddress, modified[0].getServerKey(), serverKey, path); if (toDo == AskUser.ModifiedKeyHandling.ALLOW_AND_STORE) { try { updateModifiedServerKey(serverKey, modified[0], path); knownHostsFiles.get(path).resetReloadAttributes(); } catch (IOException e) { LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path)); } } if (toDo == AskUser.ModifiedKeyHandling.DENY) { return false; } // TODO: OpenSsh disables password and keyboard-interactive // authentication in this case. Also agent and local port forwarding // are switched off. (Plus a few other things such as X11 forwarding // that are of no interest to a git client.) return true; } else if (ask.acceptUnknownKey(remoteAddress, serverKey)) { if (!filesToUse.isEmpty()) { HostKeyFile toUpdate = filesToUse.get(0); path = toUpdate.getPath(); try { if (Files.exists(path) || !askAboutNewFile || ask.createNewFile(path)) { updateKnownHostsFile(candidates, serverKey, path, config); toUpdate.resetReloadAttributes(); } } catch (Exception e) { LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path), e); } } return true; } return false; } private static class RevokedKeyException extends Exception { private static final long serialVersionUID = 1L; } private boolean find(Collection candidates, PublicKey serverKey, List entries, HostEntryPair[] modified) throws RevokedKeyException { for (HostEntryPair current : entries) { KnownHostEntry entry = current.getHostEntry(); for (SshdSocketAddress host : candidates) { if (entry.isHostMatch(host.getHostName(), host.getPort())) { boolean isRevoked = MARKER_REVOKED .equals(entry.getMarker()); if (KeyUtils.compareKeys(serverKey, current.getServerKey())) { // Exact match if (isRevoked) { throw new RevokedKeyException(); } modified[0] = null; return true; } else if (!isRevoked) { // Server sent a different key modified[0] = current; // Keep going -- maybe there's another entry for this // host } } } } return false; } private List addUserHostKeyFiles(List fileNames) { if (fileNames == null || fileNames.isEmpty()) { return Collections.emptyList(); } List userFiles = new ArrayList<>(); for (String name : fileNames) { try { Path path = Paths.get(name); HostKeyFile file = knownHostsFiles.computeIfAbsent(path, p -> new HostKeyFile(path)); userFiles.add(file); } catch (InvalidPathException e) { LOG.warn(format(SshdText.get().knownHostsInvalidPath, name)); } } return userFiles; } private void updateKnownHostsFile(Collection candidates, PublicKey serverKey, Path path, Configuration config) throws Exception { String newEntry = createHostKeyLine(candidates, serverKey, config); if (newEntry == null) { return; } LockFile lock = new LockFile(path.toFile()); if (lock.lockForAppend()) { try { try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(lock.getOutputStream(), UTF_8))) { writer.newLine(); writer.write(newEntry); writer.newLine(); } lock.commit(); } catch (IOException e) { lock.unlock(); throw e; } } else { LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate, path)); } } private void updateModifiedServerKey(PublicKey serverKey, HostEntryPair entry, Path path) throws IOException { KnownHostEntry hostEntry = entry.getHostEntry(); String oldLine = hostEntry.getConfigLine(); if (oldLine == null) { return; } String newLine = updateHostKeyLine(oldLine, serverKey); if (newLine == null || newLine.isEmpty()) { return; } if (oldLine.isEmpty() || newLine.equals(oldLine)) { // Shouldn't happen. return; } LockFile lock = new LockFile(path.toFile()); if (lock.lock()) { try { try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(lock.getOutputStream(), UTF_8)); BufferedReader reader = Files.newBufferedReader(path, UTF_8)) { boolean done = false; String line; while ((line = reader.readLine()) != null) { String toWrite = line; if (!done) { int pos = line.indexOf('#'); String toTest = pos < 0 ? line : line.substring(0, pos); if (toTest.trim().equals(oldLine)) { toWrite = newLine; done = true; } } writer.write(toWrite); writer.newLine(); } } lock.commit(); } catch (IOException e) { lock.unlock(); throw e; } } else { LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate, path)); } } private static class AskUser { public enum ModifiedKeyHandling { DENY, ALLOW, ALLOW_AND_STORE } private enum Check { ASK, DENY, ALLOW; } private final @NonNull Configuration config; private final CredentialsProvider provider; public AskUser(@NonNull Configuration config, CredentialsProvider provider) { this.config = config; this.provider = provider; } private static boolean askUser(CredentialsProvider provider, URIish uri, String prompt, String... messages) { List items = new ArrayList<>(messages.length + 1); for (String message : messages) { items.add(new CredentialItem.InformationalMessage(message)); } if (prompt != null) { CredentialItem.YesNoType answer = new CredentialItem.YesNoType( prompt); items.add(answer); return provider.get(uri, items) && answer.getValue(); } return provider.get(uri, items); } private Check checkMode(SocketAddress remoteAddress, boolean changed) { if (!(remoteAddress instanceof InetSocketAddress)) { return Check.DENY; } switch (config.getStrictHostKeyChecking()) { case REQUIRE_MATCH: return Check.DENY; case ACCEPT_ANY: return Check.ALLOW; case ACCEPT_NEW: return changed ? Check.DENY : Check.ALLOW; default: return provider == null ? Check.DENY : Check.ASK; } } public void revokedKey(SocketAddress remoteAddress, PublicKey serverKey, Path path) { if (provider == null) { return; } InetSocketAddress remote = (InetSocketAddress) remoteAddress; URIish uri = JGitUserInteraction.toURI(config.getUsername(), remote); String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256, serverKey); String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey); String keyAlgorithm = serverKey.getAlgorithm(); askUser(provider, uri, null, // format(SshdText.get().knownHostsRevokedKeyMsg, remote.getHostString(), path), format(SshdText.get().knownHostsKeyFingerprints, keyAlgorithm), md5, sha256); } public boolean acceptUnknownKey(SocketAddress remoteAddress, PublicKey serverKey) { Check check = checkMode(remoteAddress, false); if (check != Check.ASK) { return check == Check.ALLOW; } InetSocketAddress remote = (InetSocketAddress) remoteAddress; // Ask the user String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256, serverKey); String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey); String keyAlgorithm = serverKey.getAlgorithm(); String remoteHost = remote.getHostString(); URIish uri = JGitUserInteraction.toURI(config.getUsername(), remote); String prompt = SshdText.get().knownHostsUnknownKeyPrompt; return askUser(provider, uri, prompt, // format(SshdText.get().knownHostsUnknownKeyMsg, remoteHost), format(SshdText.get().knownHostsKeyFingerprints, keyAlgorithm), md5, sha256); } public ModifiedKeyHandling acceptModifiedServerKey( InetSocketAddress remoteAddress, PublicKey expected, PublicKey actual, Path path) { Check check = checkMode(remoteAddress, true); if (check == Check.ALLOW) { // Never auto-store on CHECK.ALLOW return ModifiedKeyHandling.ALLOW; } String keyAlgorithm = actual.getAlgorithm(); String remoteHost = remoteAddress.getHostString(); URIish uri = JGitUserInteraction.toURI(config.getUsername(), remoteAddress); List messages = new ArrayList<>(); String warning = format( SshdText.get().knownHostsModifiedKeyWarning, keyAlgorithm, expected.getAlgorithm(), remoteHost, KeyUtils.getFingerPrint(BuiltinDigests.md5, expected), KeyUtils.getFingerPrint(BuiltinDigests.sha256, expected), KeyUtils.getFingerPrint(BuiltinDigests.md5, actual), KeyUtils.getFingerPrint(BuiltinDigests.sha256, actual)); messages.addAll(Arrays.asList(warning.split("\n"))); //$NON-NLS-1$ if (check == Check.DENY) { if (provider != null) { messages.add(format( SshdText.get().knownHostsModifiedKeyDenyMsg, path)); askUser(provider, uri, null, messages.toArray(new String[0])); } return ModifiedKeyHandling.DENY; } // ASK -- two questions: procceed? and store? List items = new ArrayList<>(messages.size() + 2); for (String message : messages) { items.add(new CredentialItem.InformationalMessage(message)); } CredentialItem.YesNoType proceed = new CredentialItem.YesNoType( SshdText.get().knownHostsModifiedKeyAcceptPrompt); CredentialItem.YesNoType store = new CredentialItem.YesNoType( SshdText.get().knownHostsModifiedKeyStorePrompt); items.add(proceed); items.add(store); if (provider.get(uri, items) && proceed.getValue()) { return store.getValue() ? ModifiedKeyHandling.ALLOW_AND_STORE : ModifiedKeyHandling.ALLOW; } return ModifiedKeyHandling.DENY; } public boolean createNewFile(Path path) { if (provider == null) { // We can't ask, so don't create the file return false; } URIish uri = new URIish().setPath(path.toString()); return askUser(provider, uri, // format(SshdText.get().knownHostsUserAskCreationPrompt, path), // format(SshdText.get().knownHostsUserAskCreationMsg, path)); } } private static class HostKeyFile extends ModifiableFileWatcher implements Supplier> { private List entries = Collections.emptyList(); public HostKeyFile(Path path) { super(path); } @Override public List get() { Path path = getPath(); synchronized (this) { try { if (checkReloadRequired()) { entries = reload(getPath()); } } catch (IOException e) { LOG.warn(format(SshdText.get().knownHostsFileReadFailed, path)); } return Collections.unmodifiableList(entries); } } private List reload(Path path) throws IOException { try { List rawEntries = KnownHostEntryReader .readFromFile(path); updateReloadAttributes(); if (rawEntries == null || rawEntries.isEmpty()) { return Collections.emptyList(); } List newEntries = new LinkedList<>(); for (KnownHostEntry entry : rawEntries) { AuthorizedKeyEntry keyPart = entry.getKeyEntry(); if (keyPart == null) { continue; } try { PublicKey serverKey = keyPart.resolvePublicKey(null, PublicKeyEntryResolver.IGNORING); if (serverKey == null) { LOG.warn(format( SshdText.get().knownHostsUnknownKeyType, path, entry.getConfigLine())); } else { newEntries.add(new HostEntryPair(entry, serverKey)); } } catch (GeneralSecurityException e) { LOG.warn(format(SshdText.get().knownHostsInvalidLine, path, entry.getConfigLine())); } } return newEntries; } catch (FileNotFoundException | NoSuchFileException e) { resetReloadAttributes(); return Collections.emptyList(); } } } private int parsePort(String s) { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return -1; } } private SshdSocketAddress toSshdSocketAddress(@NonNull String address) { String host = null; int port = 0; if (HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == address .charAt(0)) { int end = address.indexOf( HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM); if (end <= 1) { return null; // Invalid } host = address.substring(1, end); if (end < address.length() - 1 && HostPatternsHolder.PORT_VALUE_DELIMITER == address .charAt(end + 1)) { port = parsePort(address.substring(end + 2)); } } else { int i = address .lastIndexOf(HostPatternsHolder.PORT_VALUE_DELIMITER); if (i > 0) { port = parsePort(address.substring(i + 1)); host = address.substring(0, i); } else { host = address; } } if (port < 0 || port > 65535) { return null; } return new SshdSocketAddress(host, port); } private Collection getCandidates( @NonNull String connectAddress, @NonNull InetSocketAddress remoteAddress) { Collection candidates = new TreeSet<>( SshdSocketAddress.BY_HOST_AND_PORT); candidates.add(SshdSocketAddress.toSshdSocketAddress(remoteAddress)); SshdSocketAddress address = toSshdSocketAddress(connectAddress); if (address != null) { candidates.add(address); } return candidates; } private String createHostKeyLine(Collection patterns, PublicKey key, Configuration config) throws Exception { StringBuilder result = new StringBuilder(); if (config.getHashKnownHosts()) { // SHA1 is the only algorithm for host name hashing known to OpenSSH // or to Apache MINA sshd. NamedFactory digester = KnownHostDigest.SHA1; Mac mac = digester.create(); SecureRandom prng = new SecureRandom(); byte[] salt = new byte[mac.getDefaultBlockSize()]; for (SshdSocketAddress address : patterns) { if (result.length() > 0) { result.append(','); } prng.nextBytes(salt); KnownHostHashValue.append(result, digester, salt, KnownHostHashValue.calculateHashValue( address.getHostName(), address.getPort(), mac, salt)); } } else { for (SshdSocketAddress address : patterns) { if (result.length() > 0) { result.append(','); } KnownHostHashValue.appendHostPattern(result, address.getHostName(), address.getPort()); } } result.append(' '); PublicKeyEntry.appendPublicKeyEntry(result, key); return result.toString(); } private String updateHostKeyLine(String line, PublicKey newKey) throws IOException { // Replaces an existing public key by the new key int pos = line.indexOf(' '); if (pos > 0 && line.charAt(0) == KnownHostEntry.MARKER_INDICATOR) { // We're at the end of the marker. Skip ahead to the next blank. pos = line.indexOf(' ', pos + 1); } if (pos < 0) { // Don't update if bogus format return null; } StringBuilder result = new StringBuilder(line.substring(0, pos + 1)); PublicKeyEntry.appendPublicKeyEntry(result, newKey); return result.toString(); } }