blob: 2628136773638c70fa692e4f7b022d769f6c49a3 [file] [log] [blame]
/*
* 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.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.security.spec.InvalidKeySpecException;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.MapEntryUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.io.NoCloseInputStream;
import org.apache.sshd.common.util.io.NoCloseReader;
/**
* 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>
* @see <A HREF="http://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT">sshd(8) - AUTHORIZED_KEYS_FILE_FORMAT</A>
*/
public class AuthorizedKeyEntry extends PublicKeyEntry {
public static final char BOOLEAN_OPTION_NEGATION_INDICATOR = '!';
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;
}
}
/**
* @param session The {@link SessionContext} for invoking this load command - may be {@code null}
* if not invoked within a session context (e.g., offline tool or session unknown).
* @param fallbackResolver The {@link PublicKeyEntryResolver} to consult if none of the built-in ones can
* be used. If {@code null} and no built-in resolver can be used then an
* {@link InvalidKeySpecException} is thrown.
* @return The resolved {@link PublicKey} - or {@code null} if could not be resolved.
* <B>Note:</B> may be called only after key type and data bytes have been set or
* exception(s) may be thrown
* @throws IOException If failed to decode the key
* @throws GeneralSecurityException If failed to generate the key
* @see PublicKeyEntry#resolvePublicKey(SessionContext, Map, PublicKeyEntryResolver)
*/
public PublicKey resolvePublicKey(
SessionContext session, PublicKeyEntryResolver fallbackResolver)
throws IOException, GeneralSecurityException {
return resolvePublicKey(session, getLoginOptions(), fallbackResolver);
}
@Override
public PublicKey appendPublicKey(
SessionContext session, Appendable sb, PublicKeyEntryResolver fallbackResolver)
throws IOException, GeneralSecurityException {
Map<String, String> options = getLoginOptions();
if (MapEntryUtils.isNotEmpty(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 (!"true".equals(value)) {
sb.append('=').append(value);
}
index++;
}
if (index > 0) {
sb.append(' ');
}
}
PublicKey key = super.appendPublicKey(session, sb, fallbackResolver);
String kc = getComment();
if (!GenericUtils.isEmpty(kc)) {
sb.append(' ').append(kc);
}
return key;
}
@Override // to avoid Findbugs[EQ_DOESNT_OVERRIDE_EQUALS]
public int hashCode() {
return super.hashCode();
}
@Override // to avoid Findbugs[EQ_DOESNT_OVERRIDE_EQUALS]
public boolean equals(Object obj) {
return super.equals(obj);
}
@Override
public String toString() {
String entry = super.toString();
String kc = getComment();
Map<?, ?> ko = getLoginOptions();
return (MapEntryUtils.isEmpty(ko) ? "" : ko.toString() + " ")
+ entry
+ (GenericUtils.isEmpty(kc) ? "" : " " + kc);
}
/**
* Reads read the contents of an {@code authorized_keys} 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} 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} file
*
* @param in The {@link InputStream} to use to read the contents of an {@code authorized_keys} file
* @param okToClose {@code true} if method may close the input regardless success or failure
* @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} file
*
* @param rdr The {@link Reader} to use to read the contents of an {@code authorized_keys} file
* @param okToClose {@code true} if method may close the input regardless success or failure
* @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} 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);
if (entry == null) {
continue; // null, empty or comment line
}
} 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 value Original line from an {@code authorized_keys} 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 #parseAuthorizedKeyEntry(String, PublicKeyEntryDataResolver)
*/
public static AuthorizedKeyEntry parseAuthorizedKeyEntry(String value) throws IllegalArgumentException {
return parseAuthorizedKeyEntry(value, null);
}
/**
* @param value Original line from an {@code authorized_keys} file
* @param resolver The {@link PublicKeyEntryDataResolver} to use - if {@code null} one will be
* automatically resolved from the key type
* @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
*/
public static AuthorizedKeyEntry parseAuthorizedKeyEntry(
String value, PublicKeyEntryDataResolver resolver)
throws IllegalArgumentException {
String line = GenericUtils.replaceWhitespaceAndTrim(value);
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);
Object decoder = PublicKeyEntry.getKeyDataEntryResolver(keyType);
if (decoder == null) {
decoder = KeyUtils.getPublicKeyEntryDecoder(keyType);
}
AuthorizedKeyEntry entry;
// assume this is due to the fact that it starts with login options
if (decoder == null) {
Map.Entry<String, String> comps = resolveEntryComponents(line);
entry = parseAuthorizedKeyEntry(comps.getValue());
ValidateUtils.checkTrue(entry != null, "Bad format (no key data after login options): %s", line);
entry.setLoginOptions(parseLoginOptions(comps.getKey()));
} 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, resolver);
entry.setComment(comment);
}
return entry;
}
/**
* Parses a single line from an {@code authorized_keys} file that is <U>known</U> to contain login options and
* separates it to the options and the rest of the line.
*
* @param entryLine The line to be parsed
* @return A {@link SimpleImmutableEntry} representing the parsed data where key=login options part and
* value=rest of the data - {@code null} if no data in line or line starts with comment character
* @see <A HREF="http://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT">sshd(8) -
* AUTHORIZED_KEYS_FILE_FORMAT</A>
*/
public static SimpleImmutableEntry<String, String> resolveEntryComponents(String entryLine) {
String line = GenericUtils.replaceWhitespaceAndTrim(entryLine);
if (GenericUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) {
return null;
}
for (int lastPos = 0; lastPos < line.length();) {
int startPos = line.indexOf(' ', lastPos);
if (startPos < lastPos) {
throw new IllegalArgumentException("Bad format (no key data delimiter): " + line);
}
int quotePos = line.indexOf('"', startPos + 1);
// If found quotes after the space then assume part of a login option
if (quotePos > startPos) {
lastPos = quotePos + 1;
continue;
}
String loginOptions = line.substring(0, startPos).trim();
String remainder = line.substring(startPos + 1).trim();
return new SimpleImmutableEntry<>(loginOptions, remainder);
}
throw new IllegalArgumentException("Bad format (no key data contents): " + line);
}
/**
* <P>
* Parses login options line according to
* <A HREF="http://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT">sshd(8) - AUTHORIZED_KEYS_FILE_FORMAT</A>
* guidelines. <B>Note:</B>
* </P>
*
* <UL>
* <P>
* <LI>Options that have a value are automatically stripped of any surrounding double quotes./</LI>
* </P>
*
* <P>
* <LI>Options that have no value are marked as {@code true/false} - according to the
* {@link #BOOLEAN_OPTION_NEGATION_INDICATOR}.</LI>
* </P>
*
* <P>
* <LI>Options that appear multiple times are simply concatenated using comma as separator.</LI>
* </P>
* </UL>
*
* @param options The options line to parse - ignored if {@code null}/empty/blank
* @return A {@link NavigableMap} where key=case <U>insensitive</U> option name and value=the parsed value.
* @see #addLoginOption(Map, String) addLoginOption
*/
public static NavigableMap<String, String> parseLoginOptions(String options) {
String line = GenericUtils.replaceWhitespaceAndTrim(options);
int len = GenericUtils.length(line);
if (len <= 0) {
return Collections.emptyNavigableMap();
}
NavigableMap<String, String> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
int lastPos = 0;
for (int curPos = 0; curPos < len; curPos++) {
int nextPos = line.indexOf(',', curPos);
if (nextPos < curPos) {
break;
}
// check if "true" comma or one inside quotes
int quotePos = line.indexOf('"', curPos);
if ((quotePos >= lastPos) && (quotePos < nextPos)) {
nextPos = line.indexOf('"', quotePos + 1);
if (nextPos <= quotePos) {
throw new IllegalArgumentException("Bad format (imbalanced quoted command): " + line);
}
// Make sure either comma or no more options follow the 2nd quote
for (nextPos++; nextPos < len; nextPos++) {
char ch = line.charAt(nextPos);
if (ch == ',') {
break;
}
if (ch != ' ') {
throw new IllegalArgumentException("Bad format (incorrect list format): " + line);
}
}
}
addLoginOption(optsMap, line.substring(lastPos, nextPos));
lastPos = nextPos + 1;
curPos = lastPos;
}
// Any leftovers at end of line ?
if (lastPos < len) {
addLoginOption(optsMap, line.substring(lastPos));
}
return optsMap;
}
/**
* Parses and adds a new option to the options map. If a valued option is re-specified then its value(s) are
* concatenated using comma as separator.
*
* @param optsMap Options map to add to
* @param option The option data to parse - ignored if {@code null}/empty/blank
* @return The updated entry - {@code null} if no option updated in the map
* @throws IllegalStateException If a boolean option is re-specified
*/
public static SimpleImmutableEntry<String, String> addLoginOption(Map<String, String> optsMap, String option) {
String p = GenericUtils.trimToEmpty(option);
if (GenericUtils.isEmpty(p)) {
return null;
}
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.toString(name.charAt(0) != BOOLEAN_OPTION_NEGATION_INDICATOR);
}
SimpleImmutableEntry<String, String> entry = new SimpleImmutableEntry<>(name, value.toString());
String prev = optsMap.put(entry.getKey(), entry.getValue());
if (prev != null) {
if (pos < 0) {
throw new IllegalStateException("Bad format (boolean option (" + name + ") re-specified): " + p);
}
optsMap.put(entry.getKey(), prev + "," + entry.getValue());
}
return entry;
}
}