From 7b535a8db36053802d1ff6003dee59cb0a5ef0f9 Mon Sep 17 00:00:00 2001 From: Jeroen van Erp Date: Mon, 22 May 2017 14:11:22 +0200 Subject: [PATCH] Added support for wildcard host entries in known_hosts (Fixes #331) --- .../verification/KnownHostMatchers.java | 151 +++++++++++++++++ .../ConsoleKnownHostsVerifier.java | 4 +- .../verification/OpenSSHKnownHosts.java | 159 +++++------------- .../verification/KnownHostMatchersTest.groovy | 55 ++++++ 4 files changed, 249 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/hierynomus/sshj/transport/verification/KnownHostMatchers.java create mode 100644 src/test/groovy/com/hierynomus/sshj/transport/verification/KnownHostMatchersTest.groovy diff --git a/src/main/java/com/hierynomus/sshj/transport/verification/KnownHostMatchers.java b/src/main/java/com/hierynomus/sshj/transport/verification/KnownHostMatchers.java new file mode 100644 index 00000000..2c52d96b --- /dev/null +++ b/src/main/java/com/hierynomus/sshj/transport/verification/KnownHostMatchers.java @@ -0,0 +1,151 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed 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 com.hierynomus.sshj.transport.verification; + +import net.schmizz.sshj.common.Base64; +import net.schmizz.sshj.common.IOUtils; +import net.schmizz.sshj.common.SSHException; +import net.schmizz.sshj.transport.mac.HMACSHA1; +import net.schmizz.sshj.transport.mac.MAC; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class KnownHostMatchers { + + public static HostMatcher createMatcher(String hostEntry) throws SSHException { + if (hostEntry.contains(",")) { + return new AnyHostMatcher(hostEntry); + } + if (hostEntry.startsWith("!")) { + return new NegateHostMatcher(hostEntry); + } + if (hostEntry.startsWith("|1|")) { + return new HashedHostMatcher(hostEntry); + } + if (hostEntry.contains("*") || hostEntry.contains("?")) { + return new WildcardHostMatcher(hostEntry); + } + + return new EquiHostMatcher(hostEntry); + } + + public interface HostMatcher { + boolean match(String hostname) throws IOException; + } + + private static class EquiHostMatcher implements HostMatcher { + private String host; + + public EquiHostMatcher(String host) { + this.host = host; + } + + @Override + public boolean match(String hostname) { + return host.equals(hostname); + } + } + + private static class HashedHostMatcher implements HostMatcher { + private final MAC sha1 = new HMACSHA1(); + private final String hash; + private final String salt; + private byte[] saltyBytes; + + HashedHostMatcher(String hash) throws SSHException { + this.hash = hash; + final String[] hostParts = hash.split("\\|"); + if (hostParts.length != 4) { + throw new SSHException("Unrecognized format for hashed hostname"); + } + salt = hostParts[2]; + } + + @Override + public boolean match(String hostname) throws IOException { + return hash.equals(hashHost(hostname)); + } + + private String hashHost(String host) throws IOException { + sha1.init(getSaltyBytes()); + return "|1|" + salt + "|" + Base64.encodeBytes(sha1.doFinal(host.getBytes(IOUtils.UTF8))); + } + + private byte[] getSaltyBytes() throws IOException { + if (saltyBytes == null) { + saltyBytes = Base64.decode(salt); + } + return saltyBytes; + } + + + } + + private static class AnyHostMatcher implements HostMatcher { + private final List matchers; + + AnyHostMatcher(String hostEntry) throws SSHException { + matchers = new ArrayList(); + for (String subEntry : hostEntry.split(",")) { + matchers.add(KnownHostMatchers.createMatcher(subEntry)); + } + } + + @Override + public boolean match(String hostname) throws IOException { + for (HostMatcher matcher : matchers) { + if (matcher.match(hostname)) { + return true; + } + } + return false; + } + } + + private static class NegateHostMatcher implements HostMatcher { + private final HostMatcher matcher; + + NegateHostMatcher(String hostEntry) throws SSHException { + this.matcher = createMatcher(hostEntry.substring(1)); + } + + @Override + public boolean match(String hostname) throws IOException { + return !matcher.match(hostname); + } + } + + private static class WildcardHostMatcher implements HostMatcher { + private final Pattern pattern; + + public WildcardHostMatcher(String hostEntry) { + this.pattern = Pattern.compile(hostEntry.replace(".", "\\.").replace("*", ".*").replace("?", ".")); + } + + @Override + public boolean match(String hostname) throws IOException { + return pattern.matcher(hostname).matches(); + } + + @Override + public String toString() { + return "WildcardHostMatcher[" + pattern + ']'; + } + } +} diff --git a/src/main/java/net/schmizz/sshj/transport/verification/ConsoleKnownHostsVerifier.java b/src/main/java/net/schmizz/sshj/transport/verification/ConsoleKnownHostsVerifier.java index ac1020c0..f8aad9e7 100644 --- a/src/main/java/net/schmizz/sshj/transport/verification/ConsoleKnownHostsVerifier.java +++ b/src/main/java/net/schmizz/sshj/transport/verification/ConsoleKnownHostsVerifier.java @@ -48,7 +48,7 @@ public class ConsoleKnownHostsVerifier } if (response.equalsIgnoreCase(YES)) { try { - entries().add(new SimpleEntry(null, hostname, KeyType.fromKey(key), key)); + entries().add(new HostEntry(null, hostname, KeyType.fromKey(key), key)); write(); console.printf("Warning: Permanently added '%s' (%s) to the list of known hosts.\n", hostname, type); } catch (IOException e) { @@ -60,7 +60,7 @@ public class ConsoleKnownHostsVerifier } @Override - protected boolean hostKeyChangedAction(HostEntry entry, String hostname, PublicKey key) { + protected boolean hostKeyChangedAction(KnownHostEntry entry, String hostname, PublicKey key) { final KeyType type = KeyType.fromKey(key); final String fp = SecurityUtils.getFingerprint(key); final String path = getFile().getAbsolutePath(); diff --git a/src/main/java/net/schmizz/sshj/transport/verification/OpenSSHKnownHosts.java b/src/main/java/net/schmizz/sshj/transport/verification/OpenSSHKnownHosts.java index eb1a7d0d..1c035b98 100644 --- a/src/main/java/net/schmizz/sshj/transport/verification/OpenSSHKnownHosts.java +++ b/src/main/java/net/schmizz/sshj/transport/verification/OpenSSHKnownHosts.java @@ -15,9 +15,8 @@ */ package net.schmizz.sshj.transport.verification; +import com.hierynomus.sshj.transport.verification.KnownHostMatchers; import net.schmizz.sshj.common.*; -import net.schmizz.sshj.transport.mac.HMACSHA1; -import net.schmizz.sshj.transport.mac.MAC; import org.slf4j.Logger; import java.io.*; @@ -26,7 +25,6 @@ import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.RSAPublicKeySpec; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -40,7 +38,7 @@ public class OpenSSHKnownHosts protected final Logger log; protected final File khFile; - protected final List entries = new ArrayList(); + protected final List entries = new ArrayList(); public OpenSSHKnownHosts(File khFile) throws IOException { @@ -59,7 +57,7 @@ public class OpenSSHKnownHosts String line; while ((line = br.readLine()) != null) { try { - HostEntry entry = entryFactory.parseEntry(line); + KnownHostEntry entry = entryFactory.parseEntry(line); if (entry != null) { entries.add(entry); } @@ -83,12 +81,13 @@ public class OpenSSHKnownHosts public boolean verify(final String hostname, final int port, final PublicKey key) { final KeyType type = KeyType.fromKey(key); - if (type == KeyType.UNKNOWN) + if (type == KeyType.UNKNOWN) { return false; + } final String adjustedHostname = (port != 22) ? "[" + hostname + "]:" + port : hostname; - for (HostEntry e : entries) { + for (KnownHostEntry e : entries) { try { if (e.appliesTo(type, adjustedHostname)) return e.verify(key) || hostKeyChangedAction(e, adjustedHostname, key); @@ -105,12 +104,12 @@ public class OpenSSHKnownHosts return false; } - protected boolean hostKeyChangedAction(HostEntry entry, String hostname, PublicKey key) { + protected boolean hostKeyChangedAction(KnownHostEntry entry, String hostname, PublicKey key) { log.warn("Host key for `{}` has changed!", hostname); return false; } - public List entries() { + public List entries() { return entries; } @@ -120,7 +119,7 @@ public class OpenSSHKnownHosts throws IOException { final BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(khFile)); try { - for (HostEntry entry : entries) + for (KnownHostEntry entry : entries) bos.write((entry.getLine() + LS).getBytes(IOUtils.UTF8)); } finally { bos.close(); @@ -130,7 +129,7 @@ public class OpenSSHKnownHosts /** * Append a single entry */ - public void write(HostEntry entry) + public void write(KnownHostEntry entry) throws IOException { final BufferedWriter writer = new BufferedWriter(new FileWriter(khFile, true)); try { @@ -185,7 +184,7 @@ public class OpenSSHKnownHosts EntryFactory() { } - public HostEntry parseEntry(String line) + public KnownHostEntry parseEntry(String line) throws IOException { if (isComment(line)) { return new CommentEntry(line); @@ -228,11 +227,7 @@ public class OpenSSHKnownHosts return null; } - if (isHashed(hostnames)) { - return new HashedEntry(marker, hostnames, type, key); - } else { - return new SimpleEntry(marker, hostnames, type, key); - } + return new HostEntry(marker, hostnames, type, key); } private boolean isBits(String type) { @@ -254,25 +249,22 @@ public class OpenSSHKnownHosts } - public interface HostEntry { + public interface KnownHostEntry { KeyType getType(); String getFingerprint(); - boolean appliesTo(String host) - throws IOException; + boolean appliesTo(String host) throws IOException; - boolean appliesTo(KeyType type, String host) - throws IOException; + boolean appliesTo(KeyType type, String host) throws IOException; - boolean verify(PublicKey key) - throws IOException; + boolean verify(PublicKey key) throws IOException; String getLine(); } public static class CommentEntry - implements HostEntry { + implements KnownHostEntry { private final String comment; public CommentEntry(String comment) { @@ -290,8 +282,7 @@ public class OpenSSHKnownHosts } @Override - public boolean appliesTo(String host) - throws IOException { + public boolean appliesTo(String host) throws IOException { return false; } @@ -311,17 +302,20 @@ public class OpenSSHKnownHosts } } - public static abstract class AbstractEntry - implements HostEntry { + public static class HostEntry implements KnownHostEntry { - protected final OpenSSHKnownHosts.Marker marker; + final OpenSSHKnownHosts.Marker marker; + private final String hostPart; protected final KeyType type; protected final PublicKey key; + private final KnownHostMatchers.HostMatcher matcher; - public AbstractEntry(Marker marker, KeyType type, PublicKey key) { + HostEntry(Marker marker, String hostPart, KeyType type, PublicKey key) throws SSHException { this.marker = marker; + this.hostPart = hostPart; this.type = type; this.key = key; + this.matcher = KnownHostMatchers.createMatcher(hostPart); } @Override @@ -335,8 +329,17 @@ public class OpenSSHKnownHosts } @Override - public boolean verify(PublicKey key) - throws IOException { + public boolean appliesTo(String host) throws IOException { + return matcher.match(host); + } + + @Override + public boolean appliesTo(KeyType type, String host) throws IOException { + return this.type == type && matcher.match(host); + } + + @Override + public boolean verify(PublicKey key) throws IOException { return key.equals(this.key) && marker != Marker.REVOKED; } @@ -356,88 +359,8 @@ public class OpenSSHKnownHosts return Base64.encodeBytes(buf.array(), buf.rpos(), buf.available()); } - protected abstract String getHostPart(); - } - - public static class SimpleEntry - extends AbstractEntry { - private final List hosts; - private final String hostnames; - - public SimpleEntry(Marker marker, String hostnames, KeyType type, PublicKey key) { - super(marker, type, key); - this.hostnames = hostnames; - hosts = Arrays.asList(hostnames.split(",")); - } - - @Override protected String getHostPart() { - return hostnames; - } - - @Override - public boolean appliesTo(String host) - throws IOException { - return hosts.contains(host); - } - - @Override - public boolean appliesTo(KeyType type, String host) - throws IOException { - return type == this.type && hosts.contains(host); - } - } - - public static class HashedEntry - extends AbstractEntry { - private final MAC sha1 = new HMACSHA1(); - - private final String hashedHost; - private final String salt; - - private byte[] saltyBytes; - - public HashedEntry(Marker marker, String hash, KeyType type, PublicKey key) - throws SSHException { - super(marker, type, key); - this.hashedHost = hash; - { - final String[] hostParts = hashedHost.split("\\|"); - if (hostParts.length != 4) - throw new SSHException("Unrecognized format for hashed hostname"); - salt = hostParts[2]; - } - } - - @Override - public boolean appliesTo(String host) - throws IOException { - return hashedHost.equals(hashHost(host)); - } - - @Override - public boolean appliesTo(KeyType type, String host) - throws IOException { - return this.type == type && hashedHost.equals(hashHost(host)); - } - - private String hashHost(String host) - throws IOException { - sha1.init(getSaltyBytes()); - return "|1|" + salt + "|" + Base64.encodeBytes(sha1.doFinal(host.getBytes(IOUtils.UTF8))); - } - - private byte[] getSaltyBytes() - throws IOException { - if (saltyBytes == null) { - saltyBytes = Base64.decode(salt); - } - return saltyBytes; - } - - @Override - protected String getHostPart() { - return hashedHost; + return hostPart; } } @@ -456,12 +379,12 @@ public class OpenSSHKnownHosts } public static Marker fromString(String str) { - for (Marker m: values()) - if (m.sMarker.equals(str)) + for (Marker m: values()) { + if (m.sMarker.equals(str)) { return m; + } + } return null; } - } - } diff --git a/src/test/groovy/com/hierynomus/sshj/transport/verification/KnownHostMatchersTest.groovy b/src/test/groovy/com/hierynomus/sshj/transport/verification/KnownHostMatchersTest.groovy new file mode 100644 index 00000000..4eee4756 --- /dev/null +++ b/src/test/groovy/com/hierynomus/sshj/transport/verification/KnownHostMatchersTest.groovy @@ -0,0 +1,55 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed 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 com.hierynomus.sshj.transport.verification + +import com.hierynomus.sshj.transport.verification.KnownHostMatchers +import spock.lang.Specification +import spock.lang.Unroll + +class KnownHostMatchersSpec extends Specification { + + @Unroll + def "should #yesno match host #host with pattern #pattern"() { + given: + def matcher = KnownHostMatchers.createMatcher(pattern) + + expect: + match == matcher.match(host) + + where: + pattern | host | match + "aaa.bbb.com" | "aaa.bbb.com" | true + "aaa.bbb.com" | "aaa.ccc.com" | false + "*.bbb.com" | "aaa.bbb.com" | true + "*.bbb.com" | "aaa.ccc.com" | false + "aaa.*.com" | "aaa.bbb.com" | true + "aaa.*.com" | "aaa.ccc.com" | true + "aaa.bbb.*" | "aaa.bbb.com" | true + "aaa.bbb.*" | "aaa.ccc.com" | false + "!*.bbb.com" | "aaa.bbb.com" | false + "!*.bbb.com" | "aaa.ccc.com" | true + "aaa.bbb.com,!*.ccc.com" | "xxx.yyy.com" | true + "aaa.bbb.com,!*.ccc.com" | "aaa.bbb.com" | true + "aaa.bbb.com,!*.ccc.com" | "aaa.ccc.com" | false + "aaa.b??.com" | "aaa.bbb.com" | true + "aaa.b??.com" | "aaa.bcd.com" | true + "aaa.b??.com" | "aaa.ccd.com" | false + "aaa.b??.com" | "aaa.bccd.com" | false + "|1|F1E1KeoE/eEWhi10WpGv4OdiO6Y=|3988QV0VE8wmZL7suNrYQLITLCg=" | "192.168.1.61" | true + "|1|F1E1KeoE/eEWhi10WpGv4OdiO6Y=|3988QV0VE8wmZL7suNrYQLITLCg=" | "192.168.2.61" | false + yesno = match ? "" : "no" + } +}