You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by lg...@apache.org on 2016/02/14 15:16:53 UTC

[2/3] mina-sshd git commit: [SSHD-266] Support for known hosts

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/KnownHostsServerKeyVerifier.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/KnownHostsServerKeyVerifier.java b/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/KnownHostsServerKeyVerifier.java
new file mode 100644
index 0000000..6961f901
--- /dev/null
+++ b/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/KnownHostsServerKeyVerifier.java
@@ -0,0 +1,720 @@
+/*
+ * 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.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 {
+    /**
+     * Represents an entry in the internal verifier's cach
+     * @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 = ValidateUtils.checkNotNull(entry, "No entry");
+            this.serverKey = ValidateUtils.checkNotNull(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<Collection<HostEntryPair>>(Collections.<HostEntryPair>emptyList());
+
+    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 = ValidateUtils.checkNotNull(delegate, "No delegate");
+    }
+
+    public ServerKeyVerifier getDelegateVerifier() {
+        return delegate;
+    }
+
+    @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.<HostEntryPair>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);
+        }
+
+        if (!acceptModifiedServerKey(clientSession, remoteAddress, match, serverKey)) {
+            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, HostEntryPair, 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 upodated lline 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 =
+                            ValidateUtils.checkNotNull(clientSession.getFactoryManager(), "No factory manager");
+                    Factory<? extends Random> factory =
+                            ValidateUtils.checkNotNull(manager.getRandomFactory(), "No random factory");
+                    rnd = ValidateUtils.checkNotNull(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>(String.CASE_INSENSITIVE_ORDER);
+        candidates.add(SshdSocketAddress.toAddressString(remoteAddress));
+        SocketAddress connectAddress = clientSession.getConnectAddress();
+        candidates.add(SshdSocketAddress.toAddressString(connectAddress));
+        return candidates;
+    }
+
+    /**
+     * Invoked when a matching known host key was found but it does not match
+     * the presented one.
+     *
+     * @param clientSession The {@link ClientSession}
+     * @param remoteAddress The remote host address
+     * @param match The original {@link HostEntryPair} whose key did not match
+     * @param actual The presented server {@link PublicKey}
+     * @return {@code true} if accept the server key anyway - default={@code false}
+     */
+    protected boolean acceptModifiedServerKey(
+            ClientSession clientSession, SocketAddress remoteAddress, HostEntryPair match, PublicKey actual) {
+        KnownHostEntry entry = match.getHostEntry();
+        PublicKey expected = match.getServerKey();
+        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;
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/ServerKeyVerifier.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/ServerKeyVerifier.java b/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/ServerKeyVerifier.java
index d0eb3da..5305dab 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/ServerKeyVerifier.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/keyverifier/ServerKeyVerifier.java
@@ -30,15 +30,13 @@ import org.apache.sshd.client.session.ClientSession;
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public interface ServerKeyVerifier {
-
     /**
      * Verify that the server key provided is really the one of the host.
      *
-     * @param sshClientSession the current session
-     * @param remoteAddress    the host
-     * @param serverKey        the presented key
+     * @param clientSession    the current {@link ClientSession}
+     * @param remoteAddress    the host's {@link SocketAddress}
+     * @param serverKey        the presented server {@link PublicKey}
      * @return <code>true</code> if the key is accepted for the host
      */
-    boolean verifyServerKey(ClientSession sshClientSession, SocketAddress remoteAddress, PublicKey serverKey);
-
+    boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey);
 }

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java
index 936866f..195d5ef 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/session/AbstractClientSession.java
@@ -20,6 +20,7 @@
 package org.apache.sshd.client.session;
 
 import java.io.IOException;
+import java.net.SocketAddress;
 import java.nio.file.FileSystem;
 import java.security.KeyPair;
 import java.util.List;
@@ -67,6 +68,7 @@ public abstract class AbstractClientSession extends AbstractSession implements C
     private PasswordIdentityProvider passwordIdentityProvider;
     private List<NamedFactory<UserAuth>> userAuthFactories;
     private ScpTransferEventListener scpListener;
+    private SocketAddress connectAddress;
 
     protected AbstractClientSession(ClientFactoryManager factoryManager, IoSession ioSession) {
         super(false, factoryManager, ioSession);
@@ -79,6 +81,15 @@ public abstract class AbstractClientSession extends AbstractSession implements C
     }
 
     @Override
+    public SocketAddress getConnectAddress() {
+        return connectAddress;
+    }
+
+    public void setConnectAddress(SocketAddress connectAddress) {
+        this.connectAddress = connectAddress;
+    }
+
+    @Override
     public ServerKeyVerifier getServerKeyVerifier() {
         return resolveEffectiveProvider(ServerKeyVerifier.class, serverKeyVerifier, getFactoryManager().getServerKeyVerifier());
     }

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
index 9d7acbd..db3f10f 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
@@ -19,6 +19,7 @@
 package org.apache.sshd.client.session;
 
 import java.io.IOException;
+import java.net.SocketAddress;
 import java.nio.file.FileSystem;
 import java.util.Collection;
 import java.util.Map;
@@ -77,6 +78,17 @@ public interface ClientSession extends Session, ClientAuthenticationManager {
     }
 
     /**
+     * Returns the original address (after having been translated through host
+     * configuration entries if any) that was request to connect. It contains the
+     * original host or address string that was used. <B>Note:</B> this may be
+     * different than the result of the {@link #getIoSession()} report of the
+     * remote peer
+     *
+     * @return The original requested address
+     */
+    SocketAddress getConnectAddress();
+
+    /**
      * Starts the authentication process.
      * User identities will be tried until the server successfully authenticate the user.
      * User identities must be provided before calling this method using

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/config/keys/AuthorizedKeyEntry.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/config/keys/AuthorizedKeyEntry.java b/sshd-core/src/main/java/org/apache/sshd/common/config/keys/AuthorizedKeyEntry.java
new file mode 100644
index 0000000..acf7e80
--- /dev/null
+++ b/sshd-core/src/main/java/org/apache/sshd/common/config/keys/AuthorizedKeyEntry.java
@@ -0,0 +1,361 @@
+/*
+ * 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.common.config.keys;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StreamCorruptedException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+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.Map;
+import java.util.TreeMap;
+
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.io.NoCloseInputStream;
+import org.apache.sshd.common.util.io.NoCloseReader;
+import org.apache.sshd.server.auth.pubkey.KeySetPublickeyAuthenticator;
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
+import org.apache.sshd.server.auth.pubkey.RejectAllPublickeyAuthenticator;
+
+/**
+ * Represents an entry in the user's {@code authorized_keys} file according
+ * to the <A HREF="http://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys">OpenSSH format</A>.
+ * <B>Note:</B> {@code equals/hashCode} check only the key type and data - the
+ * comment and/or login options are not considered part of equality
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class AuthorizedKeyEntry extends PublicKeyEntry {
+
+    private static final long serialVersionUID = -9007505285002809156L;
+
+    private String comment;
+    // for options that have no value, "true" is used
+    private Map<String, String> loginOptions = Collections.emptyMap();
+
+    public AuthorizedKeyEntry() {
+        super();
+    }
+
+    public String getComment() {
+        return comment;
+    }
+
+    public void setComment(String value) {
+        this.comment = value;
+    }
+
+    public Map<String, String> getLoginOptions() {
+        return loginOptions;
+    }
+
+    public void setLoginOptions(Map<String, String> value) {
+        if (value == null) {
+            this.loginOptions = Collections.emptyMap();
+        } else {
+            this.loginOptions = value;
+        }
+    }
+
+    @Override
+    public PublicKey appendPublicKey(Appendable sb, PublicKeyEntryResolver fallbackResolver) throws IOException, GeneralSecurityException {
+        Map<String, String> options = getLoginOptions();
+        if (!GenericUtils.isEmpty(options)) {
+            int index = 0;
+            for (Map.Entry<String, String> oe : options.entrySet()) {
+                String key = oe.getKey();
+                String value = oe.getValue();
+                if (index > 0) {
+                    sb.append(',');
+                }
+                sb.append(key);
+                // TODO figure out a way to remember which options where quoted
+                // TODO figure out a way to remember which options had no value
+                if (!Boolean.TRUE.toString().equals(value)) {
+                    sb.append('=').append(value);
+                }
+                index++;
+            }
+
+            if (index > 0) {
+                sb.append(' ');
+            }
+        }
+
+        PublicKey key = super.appendPublicKey(sb, fallbackResolver);
+        String kc = getComment();
+        if (!GenericUtils.isEmpty(kc)) {
+            sb.append(' ').append(kc);
+        }
+
+        return key;
+    }
+
+    @Override
+    public String toString() {
+        String entry = super.toString();
+        String kc = getComment();
+        Map<?, ?> ko = getLoginOptions();
+        return (GenericUtils.isEmpty(ko) ? "" : ko.toString() + " ")
+                + entry
+                + (GenericUtils.isEmpty(kc) ? "" : " " + kc);
+    }
+
+    public static PublickeyAuthenticator fromAuthorizedEntries(PublicKeyEntryResolver fallbackResolver, Collection<? extends AuthorizedKeyEntry> entries)
+            throws IOException, GeneralSecurityException {
+        Collection<PublicKey> keys = resolveAuthorizedKeys(fallbackResolver, entries);
+        if (GenericUtils.isEmpty(keys)) {
+            return RejectAllPublickeyAuthenticator.INSTANCE;
+        } else {
+            return new KeySetPublickeyAuthenticator(keys);
+        }
+    }
+
+    public static List<PublicKey> resolveAuthorizedKeys(PublicKeyEntryResolver fallbackResolver, Collection<? extends AuthorizedKeyEntry> entries)
+            throws IOException, GeneralSecurityException {
+        if (GenericUtils.isEmpty(entries)) {
+            return Collections.emptyList();
+        }
+
+        List<PublicKey> keys = new ArrayList<PublicKey>(entries.size());
+        for (AuthorizedKeyEntry e : entries) {
+            PublicKey k = e.resolvePublicKey(fallbackResolver);
+            if (k != null) {
+                keys.add(k);
+            }
+        }
+
+        return keys;
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param url The {@link URL} to read from
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(InputStream, boolean)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(URL url) throws IOException {
+        try (InputStream in = url.openStream()) {
+            return readAuthorizedKeys(in, true);
+        }
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param file The {@link File} to read from
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(InputStream, boolean)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(File file) throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            return readAuthorizedKeys(in, true);
+        }
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param path    {@link Path} to read from
+     * @param options The {@link OpenOption}s to use - if unspecified then appropriate
+     *                defaults assumed
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(InputStream, boolean)
+     * @see Files#newInputStream(Path, OpenOption...)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(Path path, OpenOption... options) throws IOException {
+        try (InputStream in = Files.newInputStream(path, options)) {
+            return readAuthorizedKeys(in, true);
+        }
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param filePath The file path to read from
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(InputStream, boolean)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(String filePath) throws IOException {
+        try (InputStream in = new FileInputStream(filePath)) {
+            return readAuthorizedKeys(in, true);
+        }
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param in        The {@link InputStream}
+     * @param okToClose <code>true</code> if method may close the input stream
+     *                  regardless of whether successful or failed
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(Reader, boolean)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(InputStream in, boolean okToClose) throws IOException {
+        try (Reader rdr = new InputStreamReader(NoCloseInputStream.resolveInputStream(in, okToClose), StandardCharsets.UTF_8)) {
+            return readAuthorizedKeys(rdr, true);
+        }
+    }
+
+    /**
+     * Reads read the contents of an <code>authorized_keys</code> file
+     *
+     * @param rdr       The {@link Reader}
+     * @param okToClose <code>true</code> if method may close the input stream
+     *                  regardless of whether successful or failed
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #readAuthorizedKeys(BufferedReader)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(Reader rdr, boolean okToClose) throws IOException {
+        try (BufferedReader buf = new BufferedReader(NoCloseReader.resolveReader(rdr, okToClose))) {
+            return readAuthorizedKeys(buf);
+        }
+    }
+
+    /**
+     * @param rdr The {@link BufferedReader} to use to read the contents of
+     *            an <code>authorized_keys</code> file
+     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
+     * @throws IOException If failed to read or parse the entries
+     * @see #parseAuthorizedKeyEntry(String)
+     */
+    public static List<AuthorizedKeyEntry> readAuthorizedKeys(BufferedReader rdr) throws IOException {
+        List<AuthorizedKeyEntry> entries = null;
+
+        for (String line = rdr.readLine(); line != null; line = rdr.readLine()) {
+            AuthorizedKeyEntry entry;
+            try {
+                entry = parseAuthorizedKeyEntry(line.trim());
+                if (entry == null) {    // null, empty or comment line
+                    continue;
+                }
+            } catch (RuntimeException | Error e) {
+                throw new StreamCorruptedException("Failed (" + e.getClass().getSimpleName() + ")"
+                        + " to parse key entry=" + line + ": " + e.getMessage());
+            }
+
+            if (entries == null) {
+                entries = new ArrayList<>();
+            }
+
+            entries.add(entry);
+        }
+
+        if (entries == null) {
+            return Collections.emptyList();
+        } else {
+            return entries;
+        }
+    }
+
+    /**
+     * @param line Original line from an <code>authorized_keys</code> file
+     * @return {@link AuthorizedKeyEntry} or {@code null} if the line is
+     * {@code null}/empty or a comment line
+     * @throws IllegalArgumentException If failed to parse/decode the line
+     * @see #COMMENT_CHAR
+     */
+    public static AuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException {
+        if (GenericUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) {
+            return null;
+        }
+
+        int startPos = line.indexOf(' ');
+        if (startPos <= 0) {
+            throw new IllegalArgumentException("Bad format (no key data delimiter): " + line);
+        }
+
+        int endPos = line.indexOf(' ', startPos + 1);
+        if (endPos <= startPos) {
+            endPos = line.length();
+        }
+
+        String keyType = line.substring(0, startPos);
+        PublicKeyEntryDecoder<?, ?> decoder = KeyUtils.getPublicKeyEntryDecoder(keyType);
+        final AuthorizedKeyEntry entry;
+        if (decoder == null) {  // assume this is due to the fact that it starts with login options
+            entry = parseAuthorizedKeyEntry(line.substring(startPos + 1).trim());
+            if (entry == null) {
+                throw new IllegalArgumentException("Bad format (no key data after login options): " + line);
+            }
+
+            entry.setLoginOptions(parseLoginOptions(keyType));
+        } else {
+            String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line;
+            String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null;
+            entry = parsePublicKeyEntry(new AuthorizedKeyEntry(), encData);
+            entry.setComment(comment);
+        }
+
+        return entry;
+    }
+
+    public static Map<String, String> parseLoginOptions(String options) {
+        // TODO add support if quoted values contain ','
+        String[] pairs = GenericUtils.split(options, ',');
+        if (GenericUtils.isEmpty(pairs)) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, String> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        for (String p : pairs) {
+            p = GenericUtils.trimToEmpty(p);
+            if (GenericUtils.isEmpty(p)) {
+                continue;
+            }
+
+            int pos = p.indexOf('=');
+            String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos));
+            CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1));
+            value = GenericUtils.stripQuotes(value);
+            if (value == null) {
+                value = Boolean.TRUE.toString();
+            }
+
+            String prev = optsMap.put(name, value.toString());
+            if (prev != null) {
+                throw new IllegalArgumentException("Multiple values for key=" + name + ": old=" + prev + ", new=" + value);
+            }
+        }
+
+        return optsMap;
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/mac/BaseMac.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/mac/BaseMac.java b/sshd-core/src/main/java/org/apache/sshd/common/mac/BaseMac.java
index f5e89c7..72adddd 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/mac/BaseMac.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/mac/BaseMac.java
@@ -92,15 +92,25 @@ public class BaseMac implements Mac {
     }
 
     @Override   // TODO make this a default method in Java 8
+    public byte[] doFinal() throws Exception {
+        int blockSize = getBlockSize();
+        byte[] buf = new byte[blockSize];
+        doFinal(buf);
+        return buf;
+    }
+
+    @Override   // TODO make this a default method in Java 8
     public void doFinal(byte[] buf) throws Exception {
         doFinal(buf, 0);
     }
 
     @Override
     public void doFinal(byte[] buf, int offset) throws Exception {
-        if (bsize != defbsize) {
+        int blockSize = getBlockSize();
+        int defaultSize = getDefaultBlockSize();
+        if (blockSize != defaultSize) {
             mac.doFinal(tmp, 0);
-            System.arraycopy(tmp, 0, buf, offset, bsize);
+            System.arraycopy(tmp, 0, buf, offset, blockSize);
         } else {
             mac.doFinal(buf, offset);
         }

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/mac/Mac.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/mac/Mac.java b/sshd-core/src/main/java/org/apache/sshd/common/mac/Mac.java
index 0da9472..b69ad34 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/mac/Mac.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/mac/Mac.java
@@ -33,6 +33,8 @@ public interface Mac extends MacInformation {
 
     void updateUInt(long foo);
 
+    byte[] doFinal() throws Exception;
+
     void doFinal(byte[] buf) throws Exception;
 
     void doFinal(byte[] buf, int offset) throws Exception;

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/util/Base64.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/util/Base64.java b/sshd-core/src/main/java/org/apache/sshd/common/util/Base64.java
index c392420..fe7f04d 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/util/Base64.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/util/Base64.java
@@ -33,7 +33,7 @@ import java.security.InvalidParameterException;
  *         TODO replace this class with {@code java.util.Base64} when upgrading to JDK 1.8
  * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045</a>
  */
-public class Base64 {
+public final class Base64 {
 
     /**
      * <P>Chunk size per RFC 2045 section 6.8.</P>
@@ -43,54 +43,54 @@ public class Base64 {
      *
      * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045 section 6.8</a>
      */
-    static final int CHUNK_SIZE = 76;
-
-    /**
-     * Chunk separator per RFC 2045 section 2.1.
-     *
-     * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045 section 2.1</a>
-     */
-    static final byte[] CHUNK_SEPARATOR = "\r\n".getBytes(StandardCharsets.UTF_8);
+    public static final int CHUNK_SIZE = 76;
 
     /**
      * The base length.
      */
-    static final int BASELENGTH = 255;
+    public static final int BASELENGTH = 255;
 
     /**
      * Lookup length.
      */
-    static final int LOOKUPLENGTH = 64;
+    public static final int LOOKUPLENGTH = 64;
 
     /**
      * Used to calculate the number of bits in a byte.
      */
-    static final int EIGHTBIT = 8;
+    public static final int EIGHTBIT = Byte.SIZE;
 
     /**
      * Used when encoding something which has fewer than 24 bits.
      */
-    static final int SIXTEENBIT = 16;
+    public static final int SIXTEENBIT = 2 * EIGHTBIT;
 
     /**
      * Used to determine how many bits data contains.
      */
-    static final int TWENTYFOURBITGROUP = 24;
+    public static final int TWENTYFOURBITGROUP = 3 * EIGHTBIT;
 
     /**
      * Used to get the number of Quadruples.
      */
-    static final int FOURBYTE = 4;
+    public static final int FOURBYTE = 4;
 
     /**
      * Used to test the sign of a byte.
      */
-    static final int SIGN = -128;
+    public static final int SIGN = -128;
 
     /**
      * Byte used to pad output.
      */
-    static final byte PAD = (byte) '=';
+    public static final byte PAD = (byte) '=';
+
+    /**
+     * Chunk separator per RFC 2045 section 2.1.
+     *
+     * @see <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045 section 2.1</a>
+     */
+    static final byte[] CHUNK_SEPARATOR = "\r\n".getBytes(StandardCharsets.UTF_8);
 
     // Create arrays to hold the base64 characters and a
     // lookup for base64 chars
@@ -132,7 +132,11 @@ public class Base64 {
         lookUpBase64Alphabet[63] = (byte) '/';
     }
 
-    private static boolean isBase64(byte octect) {
+    private Base64() {
+        throw new UnsupportedOperationException("No instance");
+    }
+
+    public static boolean isBase64(byte octect) {
         return octect == PAD || base64Alphabet[octect] != -1;
     }
 
@@ -145,10 +149,9 @@ public class Base64 {
      * alphabet or if the byte array is empty; false, otherwise
      */
     public static boolean isArrayByteBase64(byte[] arrayOctect) {
-
         arrayOctect = discardWhitespace(arrayOctect);
 
-        int length = arrayOctect.length;
+        int length = NumberUtils.length(arrayOctect);
         if (length == 0) {
             // shouldn't a 0 length array be valid base64 data?
             return true;
@@ -227,7 +230,8 @@ public class Base64 {
      * @return Base64-encoded data.
      */
     public static byte[] encodeBase64(byte[] binaryData, boolean isChunked) {
-        int lengthDataBits = binaryData.length * EIGHTBIT;
+        int lengthDataBytes = NumberUtils.length(binaryData);
+        int lengthDataBits = lengthDataBytes * EIGHTBIT;
         int fewerThan24bits = lengthDataBits % TWENTYFOURBITGROUP;
         int numberTriplets = lengthDataBits / TWENTYFOURBITGROUP;
         byte encodedData[];
@@ -246,7 +250,6 @@ public class Base64 {
         // for compliance with RFC 2045 MIME, then it is important to
         // allow for extra length to account for the separator(s)
         if (isChunked) {
-
             nbrChunks = CHUNK_SEPARATOR.length == 0 ? 0 : (int) Math.ceil((float) encodedDataLength / CHUNK_SIZE);
             encodedDataLength += nbrChunks * CHUNK_SEPARATOR.length;
         }
@@ -309,7 +312,6 @@ public class Base64 {
             encodedData[encodedIndex + 2] = PAD;
             encodedData[encodedIndex + 3] = PAD;
         } else if (fewerThan24bits == SIXTEENBIT) {
-
             b1 = binaryData[dataIndex];
             b2 = binaryData[dataIndex + 1];
             l = (byte) (b2 & 0x0f);
@@ -418,28 +420,50 @@ public class Base64 {
      *
      * @param data The base-64 encoded data to discard the whitespace
      *             from.
-     * @return The data, less whitespace (see RFC 2045).
+     * @return The data, less whitespace (see RFC 2045) - may be same
+     * as input if no whitespace found
      */
-    static byte[] discardWhitespace(byte[] data) {
-        byte groomedData[] = new byte[data.length];
+    public static byte[] discardWhitespace(byte[] data) {
+        if (NumberUtils.isEmpty(data)) {
+            return GenericUtils.EMPTY_BYTE_ARRAY;
+        }
+
+        byte groomedData[] = null;
         int bytesCopied = 0;
 
-        for (int i = 0; i < data.length; i++) {
-            switch (data[i]) {
-                case (byte) ' ':
-                case (byte) '\n':
-                case (byte) '\r':
-                case (byte) '\t':
-                    break;
-                default:
-                    groomedData[bytesCopied++] = data[i];
+        for (int index = 0; index < data.length; index++) {
+            byte v = data[index];
+            boolean isWhiteSpace = (v == (byte) ' ') || (v == (byte) '\t') || (v == (byte) '\r') || (v == (byte) '\n');
+            if (groomedData == null) {
+                if (isWhiteSpace) { // all values up to this index were NOT white space
+                    groomedData = new byte[data.length - 1];
+                    if (index > 0) {
+                        System.arraycopy(data, 0, groomedData, 0, index);
+                    }
+                    bytesCopied = index;
+                }
+            } else {
+                if (isWhiteSpace) {
+                    continue;
+                }
+                groomedData[bytesCopied++] = v;
             }
         }
 
-        byte packedData[] = new byte[bytesCopied];
+        if (groomedData == null) {
+            return data;    // all characters where non-whitespace
+        }
 
-        System.arraycopy(groomedData, 0, packedData, 0, bytesCopied);
+        if (bytesCopied <= 0) {
+            return GenericUtils.EMPTY_BYTE_ARRAY;
+        }
 
+        if (bytesCopied == groomedData.length) {
+            return groomedData;
+        }
+
+        byte[] packedData = new byte[bytesCopied];
+        System.arraycopy(groomedData, 0, packedData, 0, bytesCopied);
         return packedData;
     }
 

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/util/Pair.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/util/Pair.java b/sshd-core/src/main/java/org/apache/sshd/common/util/Pair.java
index 4621862..0d34fd2 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/util/Pair.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/util/Pair.java
@@ -21,7 +21,7 @@ package org.apache.sshd.common.util;
 import java.util.Objects;
 
 /**
- * Represents a pair of values
+ * Represents an un-modifiable pair of values
  *
  * @param <F> First value type
  * @param <S> Second value type

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/util/buffer/BufferUtils.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/util/buffer/BufferUtils.java b/sshd-core/src/main/java/org/apache/sshd/common/util/buffer/BufferUtils.java
index caa9a35..9181b90 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/util/buffer/BufferUtils.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/util/buffer/BufferUtils.java
@@ -314,8 +314,9 @@ public final class BufferUtils {
             // TODO use Integer.BYTES for JDK-8
             IoUtils.readFully(input, buf, offset, Integer.SIZE / Byte.SIZE);
             return getUInt(buf, offset, len);
-        } catch (IllegalArgumentException e) {
-            throw new StreamCorruptedException(e.getMessage());
+        } catch (RuntimeException | Error e) {
+            throw new StreamCorruptedException("Failed (" + e.getClass().getSimpleName() + ")"
+                    + " to read UINT value: " + e.getMessage());
         }
     }
 
@@ -408,8 +409,9 @@ public final class BufferUtils {
         try {
             int writeLen = putUInt(value, buf, off, len);
             output.write(buf, off, writeLen);
-        } catch (IllegalArgumentException e) {
-            throw new StreamCorruptedException(e.getMessage());
+        } catch (RuntimeException | Error e) {
+            throw new StreamCorruptedException("Failed (" + e.getClass().getSimpleName() + ")"
+                    + " to write UINT value=" + value + ": " + e.getMessage());
         }
     }
 

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/util/io/ModifiableFileWatcher.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/util/io/ModifiableFileWatcher.java b/sshd-core/src/main/java/org/apache/sshd/common/util/io/ModifiableFileWatcher.java
index 94b8f28..d51b7f6 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/util/io/ModifiableFileWatcher.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/util/io/ModifiableFileWatcher.java
@@ -213,9 +213,10 @@ public class ModifiableFileWatcher extends AbstractLoggingBean {
      * @param path    The {@link Path} to be checked - ignored if {@code null}
      *                or does not exist
      * @param options The {@link LinkOption}s to use to query the file's permissions
-     * @return The violated permission as {@link Pair} first is a message second is
-     * offending object {@link PosixFilePermission} or {@link String} for owner - {@code null} if
-     * no violations detected
+     * @return The violated permission as {@link Pair} where {@link Pair#getClass()}
+     * is a loggable message and {@link Pair#getSecond()} is the offending object
+     * - e.g., {@link PosixFilePermission} or {@link String} for owner. Return
+     * value is {@code null} if no violations detected
      * @throws IOException If failed to retrieve the permissions
      * @see #STRICTLY_PROHIBITED_FILE_PERMISSION
      */

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/common/util/net/SshdSocketAddress.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/common/util/net/SshdSocketAddress.java b/sshd-core/src/main/java/org/apache/sshd/common/util/net/SshdSocketAddress.java
index 7ea3806..9a2778d 100644
--- a/sshd-core/src/main/java/org/apache/sshd/common/util/net/SshdSocketAddress.java
+++ b/sshd-core/src/main/java/org/apache/sshd/common/util/net/SshdSocketAddress.java
@@ -289,6 +289,47 @@ public class SshdSocketAddress extends SocketAddress {
         return true;
     }
 
+    public static String toAddressString(SocketAddress addr) {
+        if (addr == null) {
+            return null;
+        }
+
+        if (addr instanceof InetSocketAddress) {
+            return ((InetSocketAddress) addr).getHostString();
+        }
+
+        if (addr instanceof SshdSocketAddress) {
+            return ((SshdSocketAddress) addr).getHostName();
+        }
+
+        return addr.toString();
+    }
+
+    /**
+     * <P>Converts a {@code SocketAddress} into an {@link InetSocketAddress} if possible:</P></BR>
+     * <UL>
+     *      <LI>If already an {@link InetSocketAddress} then cast it as such</LI>
+     *      <LI>If an {@code SshdSocketAddress} then invoke {@link #toInetSocketAddress()}</LI>
+     *      <LI>Otherwise, throw an exception</LI>
+     * </UL>
+     *
+     * @param remoteAddress The {@link SocketAddress} - ignored if {@code null}
+     * @return The {@link InetSocketAddress} instance
+     * @throws ClassCastException if argument is not already an {@code InetSocketAddress}
+     * or a {@code SshdSocketAddress}
+     */
+    public static InetSocketAddress toInetSocketAddress(SocketAddress remoteAddress) {
+        if (remoteAddress == null) {
+            return null;
+        } else if (remoteAddress instanceof InetSocketAddress) {
+            return (InetSocketAddress) remoteAddress;
+        } else if (remoteAddress instanceof SshdSocketAddress) {
+            return ((SshdSocketAddress) remoteAddress).toInetSocketAddress();
+        } else {
+            throw new ClassCastException("Unknown remote address type: " + remoteAddress);
+        }
+    }
+
     public static String toAddressString(InetAddress addr) {
         String ip = (addr == null) ? null : addr.toString();
         if (GenericUtils.isEmpty(ip)) {

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/509c871e/sshd-core/src/main/java/org/apache/sshd/server/config/keys/AuthorizedKeyEntry.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/config/keys/AuthorizedKeyEntry.java b/sshd-core/src/main/java/org/apache/sshd/server/config/keys/AuthorizedKeyEntry.java
deleted file mode 100644
index c57cde5..0000000
--- a/sshd-core/src/main/java/org/apache/sshd/server/config/keys/AuthorizedKeyEntry.java
+++ /dev/null
@@ -1,399 +0,0 @@
-/*
- * 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.server.config.keys;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.StreamCorruptedException;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.OpenOption;
-import java.nio.file.Path;
-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.Map;
-import java.util.TreeMap;
-
-import org.apache.sshd.common.config.keys.KeyUtils;
-import org.apache.sshd.common.config.keys.PublicKeyEntry;
-import org.apache.sshd.common.config.keys.PublicKeyEntryDecoder;
-import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
-import org.apache.sshd.common.util.GenericUtils;
-import org.apache.sshd.common.util.io.IoUtils;
-import org.apache.sshd.common.util.io.NoCloseInputStream;
-import org.apache.sshd.common.util.io.NoCloseReader;
-import org.apache.sshd.server.auth.pubkey.KeySetPublickeyAuthenticator;
-import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
-import org.apache.sshd.server.auth.pubkey.RejectAllPublickeyAuthenticator;
-
-/**
- * Represents an entry in the user's {@code authorized_keys} file according
- * to the <A HREF="http://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys">OpenSSH format</A>.
- * <B>Note:</B> {@code equals/hashCode} check only the key type and data - the
- * comment and/or login options are not considered part of equality
- *
- * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
- */
-public class AuthorizedKeyEntry extends PublicKeyEntry {
-
-    /**
-     * Standard OpenSSH authorized keys file name
-     */
-    public static final String STD_AUTHORIZED_KEYS_FILENAME = "authorized_keys";
-
-    private static final long serialVersionUID = -9007505285002809156L;
-
-    private static final class LazyDefaultAuthorizedKeysFileHolder {
-        private static final Path KEYS_FILE = PublicKeyEntry.getDefaultKeysFolderPath().resolve(STD_AUTHORIZED_KEYS_FILENAME);
-    }
-
-    private String comment;
-    // for options that have no value, "true" is used
-    private Map<String, String> loginOptions = Collections.emptyMap();
-
-    public AuthorizedKeyEntry() {
-        super();
-    }
-
-    public String getComment() {
-        return comment;
-    }
-
-    public void setComment(String value) {
-        this.comment = value;
-    }
-
-    public Map<String, String> getLoginOptions() {
-        return loginOptions;
-    }
-
-    public void setLoginOptions(Map<String, String> value) {
-        if (value == null) {
-            this.loginOptions = Collections.emptyMap();
-        } else {
-            this.loginOptions = value;
-        }
-    }
-
-    @Override
-    public PublicKey appendPublicKey(Appendable sb, PublicKeyEntryResolver fallbackResolver) throws IOException, GeneralSecurityException {
-        Map<String, String> options = getLoginOptions();
-        if (!GenericUtils.isEmpty(options)) {
-            int index = 0;
-            for (Map.Entry<String, String> oe : options.entrySet()) {
-                String key = oe.getKey();
-                String value = oe.getValue();
-                if (index > 0) {
-                    sb.append(',');
-                }
-                sb.append(key);
-                // TODO figure out a way to remember which options where quoted
-                // TODO figure out a way to remember which options had no value
-                if (!Boolean.TRUE.toString().equals(value)) {
-                    sb.append('=').append(value);
-                }
-                index++;
-            }
-
-            if (index > 0) {
-                sb.append(' ');
-            }
-        }
-
-        PublicKey key = super.appendPublicKey(sb, fallbackResolver);
-        String kc = getComment();
-        if (!GenericUtils.isEmpty(kc)) {
-            sb.append(' ').append(kc);
-        }
-
-        return key;
-    }
-
-    @Override
-    public String toString() {
-        String entry = super.toString();
-        String kc = getComment();
-        Map<?, ?> ko = getLoginOptions();
-        return (GenericUtils.isEmpty(ko) ? "" : ko.toString() + " ")
-                + entry
-                + (GenericUtils.isEmpty(kc) ? "" : " " + kc);
-    }
-
-    public static PublickeyAuthenticator fromAuthorizedEntries(PublicKeyEntryResolver fallbackResolver, Collection<? extends AuthorizedKeyEntry> entries)
-            throws IOException, GeneralSecurityException {
-        Collection<PublicKey> keys = resolveAuthorizedKeys(fallbackResolver, entries);
-        if (GenericUtils.isEmpty(keys)) {
-            return RejectAllPublickeyAuthenticator.INSTANCE;
-        } else {
-            return new KeySetPublickeyAuthenticator(keys);
-        }
-    }
-
-    public static List<PublicKey> resolveAuthorizedKeys(PublicKeyEntryResolver fallbackResolver, Collection<? extends AuthorizedKeyEntry> entries)
-            throws IOException, GeneralSecurityException {
-        if (GenericUtils.isEmpty(entries)) {
-            return Collections.emptyList();
-        }
-
-        List<PublicKey> keys = new ArrayList<PublicKey>(entries.size());
-        for (AuthorizedKeyEntry e : entries) {
-            PublicKey k = e.resolvePublicKey(fallbackResolver);
-            if (k != null) {
-                keys.add(k);
-            }
-        }
-
-        return keys;
-    }
-
-    /**
-     * @return The default {@link Path} location of the OpenSSH authorized keys file
-     */
-    @SuppressWarnings("synthetic-access")
-    public static Path getDefaultAuthorizedKeysFile() {
-        return LazyDefaultAuthorizedKeysFileHolder.KEYS_FILE;
-    }
-
-    /**
-     * Reads read the contents of the default OpenSSH <code>authorized_keys</code> file
-     *
-     * @param options The {@link OpenOption}s to use when reading the file
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there -
-     * or empty if file does not exist
-     * @throws IOException If failed to read keys from file
-     */
-    public static List<AuthorizedKeyEntry> readDefaultAuthorizedKeys(OpenOption ... options) throws IOException {
-        Path keysFile = getDefaultAuthorizedKeysFile();
-        if (Files.exists(keysFile, IoUtils.EMPTY_LINK_OPTIONS)) {
-            return readAuthorizedKeys(keysFile);
-        } else {
-            return Collections.emptyList();
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param url The {@link URL} to read from
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(InputStream, boolean)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(URL url) throws IOException {
-        try (InputStream in = url.openStream()) {
-            return readAuthorizedKeys(in, true);
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param file The {@link File} to read from
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(InputStream, boolean)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(File file) throws IOException {
-        try (InputStream in = new FileInputStream(file)) {
-            return readAuthorizedKeys(in, true);
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param path    {@link Path} to read from
-     * @param options The {@link OpenOption}s to use - if unspecified then appropriate
-     *                defaults assumed
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(InputStream, boolean)
-     * @see Files#newInputStream(Path, OpenOption...)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(Path path, OpenOption... options) throws IOException {
-        try (InputStream in = Files.newInputStream(path, options)) {
-            return readAuthorizedKeys(in, true);
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param filePath The file path to read from
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(InputStream, boolean)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(String filePath) throws IOException {
-        try (InputStream in = new FileInputStream(filePath)) {
-            return readAuthorizedKeys(in, true);
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param in        The {@link InputStream}
-     * @param okToClose <code>true</code> if method may close the input stream
-     *                  regardless of whether successful or failed
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(Reader, boolean)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(InputStream in, boolean okToClose) throws IOException {
-        try (Reader rdr = new InputStreamReader(NoCloseInputStream.resolveInputStream(in, okToClose), StandardCharsets.UTF_8)) {
-            return readAuthorizedKeys(rdr, true);
-        }
-    }
-
-    /**
-     * Reads read the contents of an <code>authorized_keys</code> file
-     *
-     * @param rdr       The {@link Reader}
-     * @param okToClose <code>true</code> if method may close the input stream
-     *                  regardless of whether successful or failed
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #readAuthorizedKeys(BufferedReader)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(Reader rdr, boolean okToClose) throws IOException {
-        try (BufferedReader buf = new BufferedReader(NoCloseReader.resolveReader(rdr, okToClose))) {
-            return readAuthorizedKeys(buf);
-        }
-    }
-
-    /**
-     * @param rdr The {@link BufferedReader} to use to read the contents of
-     *            an <code>authorized_keys</code> file
-     * @return A {@link List} of all the {@link AuthorizedKeyEntry}-ies found there
-     * @throws IOException If failed to read or parse the entries
-     * @see #parseAuthorizedKeyEntry(String)
-     */
-    public static List<AuthorizedKeyEntry> readAuthorizedKeys(BufferedReader rdr) throws IOException {
-        List<AuthorizedKeyEntry> entries = null;
-
-        for (String line = rdr.readLine(); line != null; line = rdr.readLine()) {
-            AuthorizedKeyEntry entry;
-            try {
-                entry = parseAuthorizedKeyEntry(line.trim());
-                if (entry == null) {    // null, empty or comment line
-                    continue;
-                }
-            } catch (IllegalArgumentException e) {
-                throw new StreamCorruptedException(e.getMessage());
-            }
-
-            if (entries == null) {
-                entries = new ArrayList<>();
-            }
-
-            entries.add(entry);
-        }
-
-        if (entries == null) {
-            return Collections.emptyList();
-        } else {
-            return entries;
-        }
-    }
-
-    /**
-     * @param line Original line from an <code>authorized_keys</code> file
-     * @return {@link AuthorizedKeyEntry} or {@code null} if the line is
-     * {@code null}/empty or a comment line
-     * @throws IllegalArgumentException If failed to parse/decode the line
-     * @see #COMMENT_CHAR
-     */
-    public static AuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException {
-        if (GenericUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) {
-            return null;
-        }
-
-        int startPos = line.indexOf(' ');
-        if (startPos <= 0) {
-            throw new IllegalArgumentException("Bad format (no key data delimiter): " + line);
-        }
-
-        int endPos = line.indexOf(' ', startPos + 1);
-        if (endPos <= startPos) {
-            endPos = line.length();
-        }
-
-        String keyType = line.substring(0, startPos);
-        PublicKeyEntryDecoder<?, ?> decoder = KeyUtils.getPublicKeyEntryDecoder(keyType);
-        final AuthorizedKeyEntry entry;
-        if (decoder == null) {  // assume this is due to the fact that it starts with login options
-            entry = parseAuthorizedKeyEntry(line.substring(startPos + 1).trim());
-            if (entry == null) {
-                throw new IllegalArgumentException("Bad format (no key data after login options): " + line);
-            }
-
-            entry.setLoginOptions(parseLoginOptions(keyType));
-        } else {
-            String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line;
-            String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null;
-            entry = parsePublicKeyEntry(new AuthorizedKeyEntry(), encData);
-            entry.setComment(comment);
-        }
-
-        return entry;
-    }
-
-    public static Map<String, String> parseLoginOptions(String options) {
-        // TODO add support if quoted values contain ','
-        String[] pairs = GenericUtils.split(options, ',');
-        if (GenericUtils.isEmpty(pairs)) {
-            return Collections.emptyMap();
-        }
-
-        Map<String, String> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
-        for (String p : pairs) {
-            p = GenericUtils.trimToEmpty(p);
-            if (GenericUtils.isEmpty(p)) {
-                continue;
-            }
-
-            int pos = p.indexOf('=');
-            String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos));
-            CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1));
-            value = GenericUtils.stripQuotes(value);
-            if (value == null) {
-                value = Boolean.TRUE.toString();
-            }
-
-            String prev = optsMap.put(name, value.toString());
-            if (prev != null) {
-                throw new IllegalArgumentException("Multiple values for key=" + name + ": old=" + prev + ", new=" + value);
-            }
-        }
-
-        return optsMap;
-    }
-}