/* * Copyright (C) 2018, Thomas Wolf * and other copyright owners as documented in the project's IP log. * * This program and the accompanying materials are made available * under the terms of the Eclipse Distribution License v1.0 which * accompanies this distribution, is reproduced below, and is * available at http://www.eclipse.org/org/documents/edl-v10.php * * All rights reserved. * * Redistribution and use in source and binary forms, with or * without modification, are permitted provided that the following * conditions are met: * * - Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * - Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following * disclaimer in the documentation and/or other materials provided * with the distribution. * * - Neither the name of the Eclipse Foundation, Inc. nor the * names of its contributors may be used to endorse or promote * products derived from this software without specific prior * written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ 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.File; 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.Path; import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.PublicKey; 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.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import org.apache.sshd.client.config.hosts.HostConfigEntry; import org.apache.sshd.client.config.hosts.KnownHostEntry; import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier; import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.digest.BuiltinDigests; import org.apache.sshd.common.util.io.ModifiableFileWatcher; import org.apache.sshd.common.util.net.SshdSocketAddress; 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.SshConstants; import org.eclipse.jgit.transport.URIish; 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 OpenSshServerKeyVerifier implements ServerKeyVerifier, ServerKeyLookup { // TODO: GlobalKnownHostsFile? May need some kind of LRU caching; these // files may be large! private static final Logger LOG = LoggerFactory .getLogger(OpenSshServerKeyVerifier.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<>(); private enum ModifiedKeyHandling { DENY, ALLOW, ALLOW_AND_STORE } /** * Creates a new {@link OpenSshServerKeyVerifier}. * * @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 OpenSshServerKeyVerifier(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(ClientSession session) { List filesToUse = defaultFiles; if (session instanceof JGitClientSession) { HostConfigEntry entry = ((JGitClientSession) session) .getHostConfigEntry(); if (entry instanceof JGitHostConfigEntry) { // Always true! List userFiles = addUserHostKeyFiles( ((JGitHostConfigEntry) entry).getMultiValuedOptions() .get(SshConstants.USER_KNOWN_HOSTS_FILE)); if (!userFiles.isEmpty()) { filesToUse = userFiles; } } } return filesToUse; } @Override public List lookup(ClientSession session, SocketAddress remote) { List filesToUse = getFilesToUse(session); HostKeyHelper helper = new HostKeyHelper(); List result = new ArrayList<>(); Collection candidates = helper .resolveHostNetworkIdentities(session, remote); 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); break; } } } } return result; } @Override public boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) { List filesToUse = getFilesToUse(clientSession); AskUser ask = new AskUser(); HostEntryPair[] modified = { null }; Path path = null; HostKeyHelper helper = new HostKeyHelper(); for (HostKeyFile file : filesToUse) { try { if (find(clientSession, remoteAddress, serverKey, file.get(), modified, helper)) { return true; } } catch (RevokedKeyException e) { ask.revokedKey(clientSession, 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 ModifiedKeyHandling toDo = ask.acceptModifiedServerKey( clientSession, remoteAddress, modified[0].getServerKey(), serverKey, path); if (toDo == ModifiedKeyHandling.ALLOW_AND_STORE) { try { updateModifiedServerKey(clientSession, remoteAddress, serverKey, modified[0], path, helper); knownHostsFiles.get(path).resetReloadAttributes(); } catch (IOException e) { LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path)); } } if (toDo == 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(clientSession, remoteAddress, serverKey)) { if (!filesToUse.isEmpty()) { HostKeyFile toUpdate = filesToUse.get(0); path = toUpdate.getPath(); try { updateKnownHostsFile(clientSession, remoteAddress, serverKey, path, helper); toUpdate.resetReloadAttributes(); } catch (IOException e) { LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path)); } } return true; } return false; } private static class RevokedKeyException extends Exception { private static final long serialVersionUID = 1L; } private boolean find(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, List entries, HostEntryPair[] modified, HostKeyHelper helper) throws RevokedKeyException { Collection candidates = helper .resolveHostNetworkIdentities(clientSession, remoteAddress); 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(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, Path path, HostKeyHelper updater) throws IOException { KnownHostEntry entry = updater.prepareKnownHostEntry(clientSession, remoteAddress, serverKey); if (entry == null) { return; } if (!Files.exists(path)) { if (askAboutNewFile) { CredentialsProvider provider = getCredentialsProvider( clientSession); if (provider == null) { // We can't ask, so don't create the file return; } URIish uri = new URIish().setPath(path.toString()); if (!askUser(provider, uri, // format(SshdText.get().knownHostsUserAskCreationPrompt, path), // format(SshdText.get().knownHostsUserAskCreationMsg, path))) { 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(entry.getConfigLine()); writer.newLine(); } lock.commit(); } catch (IOException e) { lock.unlock(); throw e; } } else { LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate, path)); } } private void updateModifiedServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, HostEntryPair entry, Path path, HostKeyHelper helper) throws IOException { KnownHostEntry hostEntry = entry.getHostEntry(); String oldLine = hostEntry.getConfigLine(); String newLine = helper.prepareModifiedServerKeyLine(clientSession, remoteAddress, hostEntry, oldLine, entry.getServerKey(), serverKey); if (newLine == null || newLine.isEmpty()) { return; } if (oldLine == null || 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 CredentialsProvider getCredentialsProvider( ClientSession session) { if (session instanceof JGitClientSession) { return ((JGitClientSession) session).getCredentialsProvider(); } return null; } 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(); } else { return provider.get(uri, items); } } private static class AskUser { private enum Check { ASK, DENY, ALLOW; } @SuppressWarnings("nls") private Check checkMode(ClientSession session, SocketAddress remoteAddress, boolean changed) { if (!(remoteAddress instanceof InetSocketAddress)) { return Check.DENY; } if (session instanceof JGitClientSession) { HostConfigEntry entry = ((JGitClientSession) session) .getHostConfigEntry(); String value = entry.getProperty( SshConstants.STRICT_HOST_KEY_CHECKING, "ask"); switch (value.toLowerCase(Locale.ROOT)) { case SshConstants.YES: case SshConstants.ON: return Check.DENY; case SshConstants.NO: case SshConstants.OFF: return Check.ALLOW; case "accept-new": return changed ? Check.DENY : Check.ALLOW; default: break; } } if (getCredentialsProvider(session) == null) { // This is called only for new, unknown hosts. If we have no way // to interact with the user, the fallback mode is to deny the // key. return Check.DENY; } return Check.ASK; } public void revokedKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, Path path) { CredentialsProvider provider = getCredentialsProvider( clientSession); if (provider == null) { return; } InetSocketAddress remote = (InetSocketAddress) remoteAddress; URIish uri = JGitUserInteraction.toURI(clientSession.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(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) { Check check = checkMode(clientSession, remoteAddress, false); if (check != Check.ASK) { return check == Check.ALLOW; } CredentialsProvider provider = getCredentialsProvider( clientSession); 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(clientSession.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( ClientSession clientSession, SocketAddress remoteAddress, PublicKey expected, PublicKey actual, Path path) { Check check = checkMode(clientSession, remoteAddress, true); if (check == Check.ALLOW) { // Never auto-store on CHECK.ALLOW return ModifiedKeyHandling.ALLOW; } InetSocketAddress remote = (InetSocketAddress) remoteAddress; String keyAlgorithm = actual.getAlgorithm(); String remoteHost = remote.getHostString(); URIish uri = JGitUserInteraction.toURI(clientSession.getUsername(), remote); 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$ CredentialsProvider provider = getCredentialsProvider( clientSession); 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; } } 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(); try { if (checkReloadRequired()) { if (!Files.exists(path)) { // Has disappeared. resetReloadAttributes(); return Collections.emptyList(); } LockFile lock = new LockFile(path.toFile()); if (lock.lock()) { try { entries = reload(getPath()); } finally { lock.unlock(); } } else { LOG.warn(format(SshdText.get().knownHostsFileLockedRead, path)); } } } 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( 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 e) { resetReloadAttributes(); return Collections.emptyList(); } } } // The stuff below is just a hack to avoid having to copy a lot of code from // KnownHostsServerKeyVerifier private static class HostKeyHelper extends KnownHostsServerKeyVerifier { public HostKeyHelper() { // These two arguments will never be used in any way. super((c, r, s) -> false, new File(".").toPath()); //$NON-NLS-1$ } @Override protected KnownHostEntry prepareKnownHostEntry( ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) throws IOException { // Make this method accessible try { return super.prepareKnownHostEntry(clientSession, remoteAddress, serverKey); } catch (Exception e) { throw new IOException(e.getMessage(), e); } } @Override protected String prepareModifiedServerKeyLine( ClientSession clientSession, SocketAddress remoteAddress, KnownHostEntry entry, String curLine, PublicKey expected, PublicKey actual) throws IOException { // Make this method accessible try { return super.prepareModifiedServerKeyLine(clientSession, remoteAddress, entry, curLine, expected, actual); } catch (Exception e) { throw new IOException(e.getMessage(), e); } } @Override protected Collection resolveHostNetworkIdentities( ClientSession clientSession, SocketAddress remoteAddress) { // Make this method accessible return super.resolveHostNetworkIdentities(clientSession, remoteAddress); } } }