/*
* 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;
// Cannot use forEach because the index value is not effectively final
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<>(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;
}
}