/* * Copyright (C) 2018, 2025 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.HashSet; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; 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.SshConstants; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.OpenSshCertificate; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.config.keys.UnsupportedSshPublicKey; 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$ /** Marks CA keys used for SSH certificates. */ private static final String MARKER_CA = "cert-authority"; //$NON-NLS-1$ private final boolean askAboutNewFile; private final Map knownHostsFiles = new ConcurrentHashMap<>(); private final List defaultFiles = new ArrayList<>(); private Random prng; /** * 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(); if (current.getServerKey() instanceof UnsupportedSshPublicKey) { continue; } if (!isRevoked(entry) && !isCertificateAuthority(entry)) { 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) { HostEntryPair lastModified = modified[0]; try { if (find(candidates, serverKey, file.get(), modified)) { return true; } } catch (RevokedKeyException e) { ask.revokedKey(remoteAddress, serverKey, file.getPath()); return false; } if (modified[0] != lastModified) { // Remember the file in which we might need to update the // entry path = file.getPath(); } } if (serverKey instanceof OpenSshCertificate) { return false; } 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) { if (modified[0] .getServerKey() instanceof UnsupportedSshPublicKey) { // Never update a line containing an unknown key type, // always add. addKeyToFile(filesToUse.get(0), candidates, serverKey, ask, config); } else { 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()) { addKeyToFile(filesToUse.get(0), candidates, serverKey, ask, config); } return true; } return false; } private static class RevokedKeyException extends Exception { private static final long serialVersionUID = 1L; } private static boolean isRevoked(KnownHostEntry entry) { return MARKER_REVOKED.equals(entry.getMarker()); } private static boolean isCertificateAuthority(KnownHostEntry entry) { return MARKER_CA.equals(entry.getMarker()); } private boolean find(Collection candidates, PublicKey serverKey, List entries, HostEntryPair[] modified) throws RevokedKeyException { PublicKey keyToCheck = serverKey; boolean isCert = false; String keyType = KeyUtils.getKeyType(keyToCheck); String modifiedKeyType = null; if (modified[0] != null) { modifiedKeyType = modified[0].getHostEntry().getKeyEntry() .getKeyType(); } if (serverKey instanceof OpenSshCertificate) { keyToCheck = ((OpenSshCertificate) serverKey).getCaPubKey(); isCert = true; } for (HostEntryPair current : entries) { KnownHostEntry entry = current.getHostEntry(); if (candidates.stream().anyMatch(host -> entry .isHostMatch(host.getHostName(), host.getPort()))) { boolean revoked = isRevoked(entry); boolean haveCert = isCertificateAuthority(entry); if (KeyUtils.compareKeys(keyToCheck, current.getServerKey())) { // Exact match if (revoked) { throw new RevokedKeyException(); } if (haveCert == isCert) { modified[0] = null; return true; } } if (haveCert == isCert && !haveCert && !revoked) { // Server sent a different key. if (modifiedKeyType == null) { modified[0] = current; modifiedKeyType = entry.getKeyEntry().getKeyType(); } else if (!keyType.equals(modifiedKeyType)) { String thisKeyType = entry.getKeyEntry().getKeyType(); if (isBetterMatch(keyType, thisKeyType, modifiedKeyType)) { // Since we may replace the modified[0] key, // prefer to report a key of the same key type // as having been modified. modified[0] = current; modifiedKeyType = keyType; } } // Keep going -- maybe there's another entry for this // host } } } return false; } private static boolean isBetterMatch(String keyType, String thisType, String modifiedType) { if (keyType.equals(thisType)) { return true; } // EC keys are a bit special because they encode the curve in the key // type. If we have no exactly matching EC key type in known_hosts, we // still prefer to update an existing EC key type over some other key // type. if (!keyType.startsWith("ecdsa") || !thisType.startsWith("ecdsa")) { //$NON-NLS-1$ //$NON-NLS-2$ return false; } if (!modifiedType.startsWith("ecdsa")) { //$NON-NLS-1$ return true; } // All three are EC keys. thisType doesn't match the size of keyType // (otherwise the two would have compared equal above already), so it is // not better than modifiedType. 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 addKeyToFile(HostKeyFile file, Collection candidates, PublicKey serverKey, AskUser ask, Configuration config) { Path path = file.getPath(); try { if (Files.exists(path) || !askAboutNewFile || ask.createNewFile(path)) { updateKnownHostsFile(candidates, serverKey, path, config); file.resetReloadAttributes(); } } catch (Exception e) { LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path), e); } } 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; boolean isCert = serverKey instanceof OpenSshCertificate; PublicKey keyToReport = isCert ? ((OpenSshCertificate) serverKey).getCaPubKey() : serverKey; URIish uri = JGitUserInteraction.toURI(config.getUsername(), remote); String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256, keyToReport); String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, keyToReport); String keyAlgorithm = keyToReport.getAlgorithm(); String msg = isCert ? SshdText.get().knownHostsRevokedCertificateMsg : SshdText.get().knownHostsRevokedKeyMsg; askUser(provider, uri, null, // format(msg, 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 ArrayList<>(); for (KnownHostEntry entry : rawEntries) { AuthorizedKeyEntry keyPart = entry.getKeyEntry(); if (keyPart == null) { continue; } try { PublicKey serverKey = keyPart.resolvePublicKey(null, PublicKeyEntryResolver.UNSUPPORTED); 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 = SshConstants.DEFAULT_PORT; 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); } List result = new ArrayList<>(); result.addAll(candidates); if (!remoteAddress.isUnresolved()) { SshdSocketAddress ip = new SshdSocketAddress( remoteAddress.getAddress().getHostAddress(), remoteAddress.getPort()); if (candidates.add(ip)) { result.add(ip); } } return result; } private String createHostKeyLine(Collection patterns, PublicKey key, Configuration config) throws Exception { StringBuilder result = new StringBuilder(); Set knownNames = new HashSet<>(); 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(); if (prng == null) { prng = new SecureRandom(); } byte[] salt = new byte[mac.getDefaultBlockSize()]; // For hashed hostnames, only one hashed pattern is allowed per // https://man.openbsd.org/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT if (!patterns.isEmpty()) { SshdSocketAddress address = patterns.iterator().next(); prng.nextBytes(salt); KnownHostHashValue.append(result, digester, salt, KnownHostHashValue.calculateHashValue( address.getHostName(), address.getPort(), mac, salt)); } } else { for (SshdSocketAddress address : patterns) { String tgt = address.getHostName() + ':' + address.getPort(); if (!knownNames.add(tgt)) { continue; } 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(); } }