/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.sshd.client.keyverifier;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.GeneralSecurityException;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.sshd.client.config.hosts.KnownHostEntry;
import org.apache.sshd.client.config.hosts.KnownHostHashValue;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.Factory;
import org.apache.sshd.common.FactoryManager;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.config.SshConfigFileReader;
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.mac.Mac;
import org.apache.sshd.common.random.Random;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.io.IoUtils;
import org.apache.sshd.common.util.io.ModifiableFileWatcher;
import org.apache.sshd.common.util.net.SshdSocketAddress;
/**
* @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
*/
public class KnownHostsServerKeyVerifier
extends ModifiableFileWatcher
implements ServerKeyVerifier, ModifiedServerKeyAcceptor {
/**
* Standard option used to indicate whether to use strict host key checking or not.
* Values may be "yes/no", "true/false" or "on/off"
*/
public static final String STRICT_CHECKING_OPTION = "StrictHostKeyChecking";
/**
* Standard option used to indicate alternative known hosts file location
*/
public static final String KNOWN_HOSTS_FILE_OPTION = "UserKnownHostsFile";
/**
* Represents an entry in the internal verifier's cache
* @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
*/
public static class HostEntryPair {
private KnownHostEntry hostEntry;
private PublicKey serverKey;
public HostEntryPair() {
super();
}
public HostEntryPair(KnownHostEntry entry, PublicKey key) {
this.hostEntry = Objects.requireNonNull(entry, "No entry");
this.serverKey = Objects.requireNonNull(key, "No key");
}
public KnownHostEntry getHostEntry() {
return hostEntry;
}
public void setHostEntry(KnownHostEntry hostEntry) {
this.hostEntry = hostEntry;
}
public PublicKey getServerKey() {
return serverKey;
}
public void setServerKey(PublicKey serverKey) {
this.serverKey = serverKey;
}
@Override
public String toString() {
return String.valueOf(getHostEntry());
}
}
protected final Object updateLock = new Object();
private final ServerKeyVerifier delegate;
private final AtomicReference<Collection<HostEntryPair>> keysHolder =
new AtomicReference<>(Collections.emptyList());
private ModifiedServerKeyAcceptor modKeyAcceptor;
public KnownHostsServerKeyVerifier(ServerKeyVerifier delegate, Path file) {
this(delegate, file, IoUtils.EMPTY_LINK_OPTIONS);
}
public KnownHostsServerKeyVerifier(ServerKeyVerifier delegate, Path file, LinkOption... options) {
super(file, options);
this.delegate = Objects.requireNonNull(delegate, "No delegate");
}
public ServerKeyVerifier getDelegateVerifier() {
return delegate;
}
/**
* @return The delegate {@link ModifiedServerKeyAcceptor} to consult
* if a server presents a modified key. If {@code null} then assumed
* to reject such a modification
*/
public ModifiedServerKeyAcceptor getModifiedServerKeyAcceptor() {
return modKeyAcceptor;
}
/**
* @param acceptor The delegate {@link ModifiedServerKeyAcceptor} to
* consult if a server presents a modified key. If {@code null} then
* assumed to reject such a modification
*/
public void setModifiedServerKeyAcceptor(ModifiedServerKeyAcceptor acceptor) {
modKeyAcceptor = acceptor;
}
@Override
public boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) {
Collection<HostEntryPair> knownHosts = getLoadedHostsEntries();
try {
if (checkReloadRequired()) {
Path file = getPath();
if (exists()) {
knownHosts = reloadKnownHosts(file);
} else {
if (log.isDebugEnabled()) {
log.debug("verifyServerKey({})[{}] missing known hosts file {}",
clientSession, remoteAddress, file);
}
knownHosts = Collections.emptyList();
}
setLoadedHostsEntries(knownHosts);
}
} catch (Throwable t) {
return acceptIncompleteHostKeys(clientSession, remoteAddress, serverKey, t);
}
return acceptKnownHostEntries(clientSession, remoteAddress, serverKey, knownHosts);
}
protected Collection<HostEntryPair> getLoadedHostsEntries() {
return keysHolder.get();
}
protected void setLoadedHostsEntries(Collection<HostEntryPair> keys) {
keysHolder.set(keys);
}
/**
* @param file The {@link Path} to reload from
* @return A {@link List} of the loaded {@link HostEntryPair}s - may be {@code null}/empty
* @throws IOException If failed to parse the file
* @throws GeneralSecurityException If failed to resolve the encoded public keys
*/
protected List<HostEntryPair> reloadKnownHosts(Path file) throws IOException, GeneralSecurityException {
Collection<KnownHostEntry> entries = KnownHostEntry.readKnownHostEntries(file);
if (log.isDebugEnabled()) {
log.debug("reloadKnownHosts({}) loaded {} entries", file, entries.size());
}
updateReloadAttributes();
if (GenericUtils.isEmpty(entries)) {
return Collections.emptyList();
}
List<HostEntryPair> keys = new ArrayList<>(entries.size());
PublicKeyEntryResolver resolver = getFallbackPublicKeyEntryResolver();
for (KnownHostEntry entry : entries) {
try {
PublicKey key = resolveHostKey(entry, resolver);
if (key != null) {
keys.add(new HostEntryPair(entry, key));
}
} catch (Throwable t) {
log.warn("reloadKnownHosts({}) failed ({}) to load key of {}: {}",
file, t.getClass().getSimpleName(), entry, t.getMessage());
if (log.isDebugEnabled()) {
log.debug("reloadKnownHosts(" + file + ") key=" + entry + " load failure details", t);
}
}
}
return keys;
}
/**
* Recover the associated public key from a known host entry
*
* @param entry The {@link KnownHostEntry} - ignored if {@code null}
* @param resolver The {@link PublicKeyEntryResolver} to use if immediate
* - decoding does not work - ignored if {@code null}
* @return The extracted {@link PublicKey} - {@code null} if none
* @throws IOException If failed to decode the key
* @throws GeneralSecurityException If failed to generate the key
* @see #getFallbackPublicKeyEntryResolver()
* @see AuthorizedKeyEntry#resolvePublicKey(PublicKeyEntryResolver)
*/
protected PublicKey resolveHostKey(KnownHostEntry entry, PublicKeyEntryResolver resolver)
throws IOException, GeneralSecurityException {
if (entry == null) {
return null;
}
AuthorizedKeyEntry authEntry = ValidateUtils.checkNotNull(entry.getKeyEntry(), "No key extracted from %s", entry);
PublicKey key = authEntry.resolvePublicKey(resolver);
if (log.isDebugEnabled()) {
log.debug("resolveHostKey({}) loaded {}-{}", entry, KeyUtils.getKeyType(key), KeyUtils.getFingerPrint(key));
}
return key;
}
protected PublicKeyEntryResolver getFallbackPublicKeyEntryResolver() {
return PublicKeyEntryResolver.IGNORING;
}
protected boolean acceptKnownHostEntries(
ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, Collection<HostEntryPair> knownHosts) {
// TODO allow for several candidates and check if ANY of them matches the key and has 'revoked' marker
HostEntryPair match = findKnownHostEntry(clientSession, remoteAddress, knownHosts);
if (match == null) {
return acceptUnknownHostKey(clientSession, remoteAddress, serverKey);
}
KnownHostEntry entry = match.getHostEntry();
PublicKey expected = match.getServerKey();
if (KeyUtils.compareKeys(expected, serverKey)) {
return acceptKnownHostEntry(clientSession, remoteAddress, serverKey, entry);
}
try {
if (!acceptModifiedServerKey(clientSession, remoteAddress, entry, expected, serverKey)) {
return false;
}
} catch (Throwable t) {
log.warn("acceptKnownHostEntries({})[{}] failed ({}) to accept modified server key: {}",
clientSession, remoteAddress, t.getClass().getSimpleName(), t.getMessage());
if (log.isDebugEnabled()) {
log.debug("acceptKnownHostEntries(" + clientSession + ")[" + remoteAddress + "]"
+ " modified server key acceptance failure details", t);
}
return false;
}
Path file = getPath();
try {
updateModifiedServerKey(clientSession, remoteAddress, match, serverKey, file, knownHosts);
} catch (Throwable t) {
handleModifiedServerKeyUpdateFailure(clientSession, remoteAddress, match, serverKey, file, knownHosts, t);
}
return true;
}
/**
* Invoked if a matching host entry was found, but the key did not match and
* {@link #acceptModifiedServerKey(ClientSession, SocketAddress, KnownHostEntry, PublicKey, PublicKey)}
* returned {@code true}. By default it locates the line to be updated and updates only
* its key data, marking the file for reload on next verification just to be
* on the safe side.
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param match The {@link HostEntryPair} whose key does not match
* @param actual The presented server {@link PublicKey} to be updated
* @param file The file {@link Path} to be updated
* @param knownHosts The currently loaded entries
* @throws Exception If failed to update the file - <B>Note:</B> this may mean the
* file is now corrupted
* @see #handleModifiedServerKeyUpdateFailure(ClientSession, SocketAddress, HostEntryPair, PublicKey, Path, Collection, Throwable)
* @see #prepareModifiedServerKeyLine(ClientSession, SocketAddress, KnownHostEntry, String, PublicKey, PublicKey)
*/
protected void updateModifiedServerKey(
ClientSession clientSession, SocketAddress remoteAddress, HostEntryPair match, PublicKey actual,
Path file, Collection<HostEntryPair> knownHosts)
throws Exception {
KnownHostEntry entry = match.getHostEntry();
String matchLine = ValidateUtils.checkNotNullAndNotEmpty(entry.getConfigLine(), "No entry config line");
String newLine = prepareModifiedServerKeyLine(clientSession, remoteAddress, entry, matchLine, match.getServerKey(), actual);
if (GenericUtils.isEmpty(newLine)) {
if (log.isDebugEnabled()) {
log.debug("updateModifiedServerKey({})[{}] no replacement generated for {}",
clientSession, remoteAddress, matchLine);
}
return;
}
if (matchLine.equals(newLine)) {
if (log.isDebugEnabled()) {
log.debug("updateModifiedServerKey({})[{}] unmodified updated line for {}",
clientSession, remoteAddress, matchLine);
}
return;
}
List<String> lines = new ArrayList<>();
synchronized (updateLock) {
int matchingIndex = -1; // read all lines but replace the
try (BufferedReader rdr = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
for (String line = rdr.readLine(); line != null; line = rdr.readLine()) {
// skip if already replaced the original line
if (matchingIndex >= 0) {
lines.add(line);
continue;
}
line = GenericUtils.trimToEmpty(line);
if (GenericUtils.isEmpty(line)) {
lines.add(line);
continue;
}
int pos = line.indexOf(SshConfigFileReader.COMMENT_CHAR);
if (pos == 0) {
lines.add(line);
continue;
}
if (pos > 0) {
line = line.substring(0, pos);
line = line.trim();
}
if (!matchLine.equals(line)) {
lines.add(line);
continue;
}
lines.add(newLine);
matchingIndex = lines.size();
}
}
ValidateUtils.checkTrue(matchingIndex >= 0, "No match found for line=%s", matchLine);
try (Writer w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
for (String l : lines) {
w.append(l).append(IoUtils.EOL);
}
}
synchronized (match) {
match.setServerKey(actual);
entry.setConfigLine(newLine);
}
}
if (log.isDebugEnabled()) {
log.debug("updateModifiedServerKey({}) replaced '{}' with '{}'", file, matchLine, newLine);
}
resetReloadAttributes(); // force reload on next verification
}
/**
* Invoked by {@link #updateModifiedServerKey(ClientSession, SocketAddress, HostEntryPair, PublicKey, Path, Collection)}
* in order to prepare the replacement - by default it replaces the key part with the new one
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param entry The {@link KnownHostEntry}
* @param curLine The current entry line data
* @param expected The expected {@link PublicKey}
* @param actual The present key to be update
* @return The updated line - ignored if {@code null}/empty or same as original one
* @throws Exception if failed to prepare the line
*/
protected String prepareModifiedServerKeyLine(
ClientSession clientSession, SocketAddress remoteAddress, KnownHostEntry entry,
String curLine, PublicKey expected, PublicKey actual)
throws Exception {
if ((entry == null) || GenericUtils.isEmpty(curLine)) {
return curLine; // just to be on the safe side
}
int pos = curLine.indexOf(' ');
if (curLine.charAt(0) == KnownHostEntry.MARKER_INDICATOR) {
// skip marker till next token
for (pos++; pos < curLine.length(); pos++) {
if (curLine.charAt(pos) != ' ') {
break;
}
}
pos = (pos < curLine.length()) ? curLine.indexOf(' ', pos) : -1;
}
ValidateUtils.checkTrue((pos > 0) && (pos < (curLine.length() - 1)), "Missing encoded key in line=%s", curLine);
StringBuilder sb = new StringBuilder(curLine.length());
sb.append(curLine.substring(0, pos)); // copy the marker/patterns as-is
PublicKeyEntry.appendPublicKeyEntry(sb.append(' '), actual);
return sb.toString();
}
/**
* Invoked if {@code #updateModifiedServerKey(ClientSession, SocketAddress, HostEntryPair, PublicKey, Path)}
* throws an exception. This may mean the file is corrupted, but it can be recovered from the known hosts
* that are being provided. By default, it only logs a warning and does not attempt to recover the file
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param match The {@link HostEntryPair} whose key does not match
* @param serverKey The presented server {@link PublicKey} to be updated
* @param file The file {@link Path} to be updated
* @param knownHosts The currently cached entries (may be {@code null}/empty)
* @param reason The failure reason
*/
protected void handleModifiedServerKeyUpdateFailure(
ClientSession clientSession, SocketAddress remoteAddress, HostEntryPair match,
PublicKey serverKey, Path file, Collection<HostEntryPair> knownHosts, Throwable reason) {
// NOTE !!! this may mean the file is corrupted, but it can be recovered from the known hosts
log.warn("acceptKnownHostEntries({})[{}] failed ({}) to update modified server key of {}: {}",
clientSession, remoteAddress, reason.getClass().getSimpleName(), match, reason.getMessage());
if (log.isDebugEnabled()) {
log.debug("acceptKnownHostEntries(" + clientSession + ")[" + remoteAddress + "]"
+ " modified key update failure details", reason);
}
}
/**
* Invoked <U>after</U> known host entry located and keys match - by default
* checks that entry has not been revoked
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The presented server {@link PublicKey}
* @param entry The {@link KnownHostEntry} value - if {@code null} then no
* known matching host entry was found - default will call
* {@link #acceptUnknownHostKey(ClientSession, SocketAddress, PublicKey)}
* @return {@code true} if OK to accept the server
*/
protected boolean acceptKnownHostEntry(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, KnownHostEntry entry) {
if (entry == null) { // not really expected, but manage it
return acceptUnknownHostKey(clientSession, remoteAddress, serverKey);
}
if ("revoked".equals(entry.getMarker())) {
log.debug("acceptKnownHostEntry({})[{}] key={}-{} marked as {}",
clientSession, remoteAddress, KeyUtils.getKeyType(serverKey), KeyUtils.getFingerPrint(serverKey), entry.getMarker());
return false;
}
if (log.isDebugEnabled()) {
log.debug("acceptKnownHostEntry({})[{}] matched key={}-{}",
clientSession, remoteAddress, KeyUtils.getKeyType(serverKey), KeyUtils.getFingerPrint(serverKey));
}
return true;
}
protected HostEntryPair findKnownHostEntry(
ClientSession clientSession, SocketAddress remoteAddress, Collection<HostEntryPair> knownHosts) {
if (GenericUtils.isEmpty(knownHosts)) {
return null;
}
Collection<String> candidates = resolveHostNetworkIdentities(clientSession, remoteAddress);
if (log.isDebugEnabled()) {
log.debug("findKnownHostEntry({})[{}] host network identities: {}",
clientSession, remoteAddress, candidates);
}
if (GenericUtils.isEmpty(candidates)) {
return null;
}
for (HostEntryPair match : knownHosts) {
KnownHostEntry entry = match.getHostEntry();
for (String host : candidates) {
try {
if (entry.isHostMatch(host)) {
if (log.isDebugEnabled()) {
log.debug("findKnownHostEntry({})[{}] matched host={} for entry={}",
clientSession, remoteAddress, host, entry);
}
return match;
}
} catch (RuntimeException | Error e) {
log.warn("findKnownHostEntry({})[{}] failed ({}) to check host={} for entry={}: {}",
clientSession, remoteAddress, e.getClass().getSimpleName(),
host, entry.getConfigLine(), e.getMessage());
if (log.isDebugEnabled()) {
log.debug("findKnownHostEntry(" + clientSession + ") host=" + host + ", entry=" + entry + " match failure details", e);
}
}
}
}
return null; // no match found
}
/**
* Called if failed to reload known hosts - by default invokes
* {@link #acceptUnknownHostKey(ClientSession, SocketAddress, PublicKey)}
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The presented server {@link PublicKey}
* @param reason The {@link Throwable} that indicates the reload failure
* @return {@code true} if accept the server key anyway
* @see #acceptUnknownHostKey(ClientSession, SocketAddress, PublicKey)
*/
protected boolean acceptIncompleteHostKeys(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey, Throwable reason) {
log.warn("Failed ({}) to reload server keys from {}: {}",
reason.getClass().getSimpleName(), getPath(), reason.getMessage());
if (log.isDebugEnabled()) {
log.debug(getPath() + " reload failure details", reason);
}
return acceptUnknownHostKey(clientSession, remoteAddress, serverKey);
}
/**
* Invoked if none of the known hosts matches the current one - by default invokes the delegate.
* If the delegate accepts the key, then it is <U>appended</U> to the currently monitored entries
* and the file is updated
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The presented server {@link PublicKey}
* @return {@code true} if accept the server key
* @see #updateKnownHostsFile(ClientSession, SocketAddress, PublicKey, Path, Collection)
* @see #handleKnownHostsFileUpdateFailure(ClientSession, SocketAddress, PublicKey, Path, Collection, Throwable)
*/
protected boolean acceptUnknownHostKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) {
if (log.isDebugEnabled()) {
log.debug("acceptUnknownHostKey({}) host={}, key={}",
clientSession, remoteAddress, KeyUtils.getFingerPrint(serverKey));
}
if (delegate.verifyServerKey(clientSession, remoteAddress, serverKey)) {
Path file = getPath();
Collection<HostEntryPair> keys = getLoadedHostsEntries();
try {
updateKnownHostsFile(clientSession, remoteAddress, serverKey, file, keys);
} catch (Throwable t) {
handleKnownHostsFileUpdateFailure(clientSession, remoteAddress, serverKey, file, keys, t);
}
return true;
}
return false;
}
/**
* Invoked when {@link #updateKnownHostsFile(ClientSession, SocketAddress, PublicKey, Path, Collection)} fails - by
* default just issues a warning. <B>Note:</B> there is a chance that the file is now corrupted and
* cannot be re-used, so we provide a way to recover it via overriding this method and using the cached
* entries to re-created it.
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The server {@link PublicKey} that was attempted to update
* @param file The file {@link Path} to be updated
* @param knownHosts The currently known entries (may be {@code null}/empty
* @param reason The failure reason
*/
protected void handleKnownHostsFileUpdateFailure(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey,
Path file, Collection<HostEntryPair> knownHosts, Throwable reason) {
log.warn("handleKnownHostsFileUpdateFailure({})[{}] failed ({}) to update key={}-{} in {}: {}",
clientSession, remoteAddress, reason.getClass().getSimpleName(),
KeyUtils.getKeyType(serverKey), KeyUtils.getFingerPrint(serverKey),
file, reason.getMessage());
if (log.isDebugEnabled()) {
log.debug("handleKnownHostsFileUpdateFailure(" + clientSession + ")[" + remoteAddress + "]"
+ " file update failure details", reason);
}
}
/**
* Invoked if a new previously unknown host key has been accepted - by default
* appends a new entry at the end of the currently monitored known hosts file
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The server {@link PublicKey} that to update
* @param file The file {@link Path} to be updated
* @param knownHosts The currently cached entries (may be {@code null}/empty)
* @return The generated {@link KnownHostEntry} or {@code null} if nothing updated.
* If anything updated then the file will be re-loaded on next verification
* regardless of which server is verified
* @throws Exception If failed to update the file - <B>Note:</B> in this case
* the file may be corrupted so {@link #handleKnownHostsFileUpdateFailure(ClientSession, SocketAddress, PublicKey, Path, Collection, Throwable)}
* will be called in order to enable recovery of its data
* @see #resetReloadAttributes()
*/
protected KnownHostEntry updateKnownHostsFile(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey,
Path file, Collection<HostEntryPair> knownHosts) throws Exception {
KnownHostEntry entry = prepareKnownHostEntry(clientSession, remoteAddress, serverKey);
if (entry == null) {
if (log.isDebugEnabled()) {
log.debug("updateKnownHostsFile({})[{}] no entry generated for key={}",
clientSession, remoteAddress, KeyUtils.getFingerPrint(serverKey));
}
return null;
}
String line = entry.getConfigLine();
byte[] lineData = line.getBytes(StandardCharsets.UTF_8);
boolean reuseExisting = Files.exists(file) && (Files.size(file) > 0);
synchronized (updateLock) {
try (OutputStream output = reuseExisting ? Files.newOutputStream(file, StandardOpenOption.APPEND) : Files.newOutputStream(file)) {
if (reuseExisting) {
output.write(IoUtils.getEOLBytes()); // separate from previous lines
}
output.write(lineData);
}
}
if (log.isDebugEnabled()) {
log.debug("updateKnownHostsFile({}) updated: {}", file, entry);
}
resetReloadAttributes(); // force reload on next verification
return entry;
}
/**
* Invoked by {@link #updateKnownHostsFile(ClientSession, SocketAddress, PublicKey, Path, Collection)}
* in order to generate the host entry to be written
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param serverKey The server {@link PublicKey} that was attempted to update
* @return The {@link KnownHostEntry} to use - if {@code null} then entry is
* not updated in the file
* @throws Exception If failed to generate the entry - e.g. failed to hash
* @see #resolveHostNetworkIdentities(ClientSession, SocketAddress)
* @see KnownHostEntry#getConfigLine()
*/
protected KnownHostEntry prepareKnownHostEntry(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) throws Exception {
Collection<String> patterns = resolveHostNetworkIdentities(clientSession, remoteAddress);
if (GenericUtils.isEmpty(patterns)) {
return null;
}
StringBuilder sb = new StringBuilder(Byte.MAX_VALUE);
Random rnd = null;
for (String hostIdentity : patterns) {
if (sb.length() > 0) {
sb.append(',');
}
NamedFactory<Mac> digester = getHostValueDigester(clientSession, remoteAddress, hostIdentity);
if (digester != null) {
if (rnd == null) {
FactoryManager manager =
Objects.requireNonNull(clientSession.getFactoryManager(), "No factory manager");
Factory<? extends Random> factory =
Objects.requireNonNull(manager.getRandomFactory(), "No random factory");
rnd = Objects.requireNonNull(factory.create(), "No randomizer created");
}
Mac mac = digester.create();
int blockSize = mac.getDefaultBlockSize();
byte[] salt = new byte[blockSize];
rnd.fill(salt);
byte[] digestValue = KnownHostHashValue.calculateHashValue(hostIdentity, mac, salt);
KnownHostHashValue.append(sb, digester, salt, digestValue);
} else {
sb.append(hostIdentity);
}
}
PublicKeyEntry.appendPublicKeyEntry(sb.append(' '), serverKey);
return KnownHostEntry.parseKnownHostEntry(sb.toString());
}
/**
* Invoked by {@link #prepareKnownHostEntry(ClientSession, SocketAddress, PublicKey)}
* in order to query whether to use a hashed value instead of a plain one for the
* written host name/address - default returns {@code null} - i.e., no hashing
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @param hostIdentity The entry's host name/address
* @return The digester {@link NamedFactory} - {@code null} if no hashing is to be made
*/
protected NamedFactory<Mac> getHostValueDigester(ClientSession clientSession, SocketAddress remoteAddress, String hostIdentity) {
return null;
}
/**
* Retrieves the host identities to be used when matching or updating an entry
* for it - by default returns the reported remote address and the original
* connection target host name/address (if same, then only one value is returned)
*
* @param clientSession The {@link ClientSession}
* @param remoteAddress The remote host address
* @return A {@link Collection} of the names/addresses to use - if {@code null}/empty
* then ignored (i.e., no matching is done or no entry is generated)
* @see ClientSession#getConnectAddress()
* @see SshdSocketAddress#toAddressString(SocketAddress)
*/
protected Collection<String> resolveHostNetworkIdentities(ClientSession clientSession, SocketAddress remoteAddress) {
/*
* NOTE !!! we do not resolve the fully-qualified name to avoid long DNS timeouts.
* Instead we use the reported peer address and the original connection target host
*/
Collection<String> candidates = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
candidates.add(SshdSocketAddress.toAddressString(remoteAddress));
SocketAddress connectAddress = clientSession.getConnectAddress();
candidates.add(SshdSocketAddress.toAddressString(connectAddress));
return candidates;
}
@Override
public boolean acceptModifiedServerKey(ClientSession clientSession, SocketAddress remoteAddress,
KnownHostEntry entry, PublicKey expected, PublicKey actual)
throws Exception {
ModifiedServerKeyAcceptor acceptor = getModifiedServerKeyAcceptor();
if (acceptor != null) {
return acceptor.acceptModifiedServerKey(clientSession, remoteAddress, entry, expected, actual);
}
log.warn("acceptModifiedServerKey({}) mismatched keys presented by {} for entry={}: expected={}-{}, actual={}-{}",
clientSession, remoteAddress, entry,
KeyUtils.getKeyType(expected), KeyUtils.getFingerPrint(expected),
KeyUtils.getKeyType(actual), KeyUtils.getFingerPrint(actual));
return false;
}
}