Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ssh-key/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name = "ssh-key"
version = "0.3.0-pre" # Also update html_root_url in lib.rs when bumping this
description = """
Pure Rust implementation of SSH key file format decoders/encoders as described
in RFC4253 and RFC4716 as well as the OpenSSH key formats. Supports "heapless"
`no_std` embedded targets with an optional `alloc` feature (Ed25519 and ECDSA only)
in RFC4253 and RFC4716 as well as the OpenSSH key formats and `authorized_keys`.
Supports "heapless" `no_std` embedded targets with an optional `alloc` feature.
"""
authors = ["RustCrypto Developers"]
license = "Apache-2.0 OR MIT"
Expand Down
5 changes: 4 additions & 1 deletion ssh-key/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
## About

Pure Rust implementation of SSH key file format decoders/encoders as described
in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specification.
in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specification
and `authorized_keys` files.

## Features

Expand All @@ -23,6 +24,7 @@ in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specificat
- [x] ECDSA (`no_std` "heapless")
- [x] Ed25519 (`no_std` "heapless")
- [x] RSA (`no_std` + `alloc`)
- [x] Parsing `autorized_keys` files
- [x] Built-in zeroize support for private keys

#### TODO:
Expand All @@ -31,6 +33,7 @@ in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specificat
- [ ] Encrypted private key support
- [ ] Legacy SSH key (pre-OpenSSH) format support
- [ ] Integrations with other RustCrypto crates (e.g. `ecdsa`, `ed25519`, `rsa`)
- [ ] FIDO2 key support

## Minimum Supported Rust Version

Expand Down
145 changes: 145 additions & 0 deletions ssh-key/src/authorized_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Parser for `AuthorizedKeysFile`-formatted data.

use crate::{Error, PublicKey, Result};

#[cfg(feature = "std")]
use std::{fs, path::Path};

/// Character that begins a comment
const COMMENT_DELIMITER: char = '#';

/// Parser for `AuthorizedKeysFile`-formatted data, typically found in
/// `~/.ssh/authorized_keys`.
///
/// For a full description of the format, see:
/// <https://man7.org/linux/man-pages/man8/sshd.8.html#AUTHORIZED_KEYS_FILE_FORMAT>
///
/// Each line of the file consists of a single public key. Blank lines are ignored.
///
/// Public keys consist of the following space-separated fields:
///
/// ```text
/// options, keytype, base64-encoded key, comment
/// ```
///
/// - The options field is optional.
/// - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
/// `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
/// - The comment field is not used for anything (but may be convenient for the user to identify
/// the key).
pub struct AuthorizedKeys<'a> {
/// Lines of the file being iterated over
lines: core::str::Lines<'a>,
}

impl<'a> AuthorizedKeys<'a> {
/// Create a new parser for the given input buffer.
pub fn new(input: &'a str) -> Self {
Self {
lines: input.lines(),
}
}

/// Read a file from the filesystem, calling the given closure with an
/// [`AuthorizedKeys`] parser which operates over a temporary buffer.
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn read_file<T, F>(path: impl AsRef<Path>, f: F) -> Result<T>
where
F: FnOnce(AuthorizedKeys<'_>) -> Result<T>,
{
// TODO(tarcieri): permissions checks
let input = fs::read_to_string(path)?;
f(AuthorizedKeys::new(&input))
}
}

impl<'a> Iterator for AuthorizedKeys<'a> {
type Item = Result<Entry<'a>>;

fn next(&mut self) -> Option<Result<Entry<'a>>> {
loop {
let result = LineParser::new(self.lines.next()?);

match result {
Ok(LineParser {
options_str: None,
public_key_str: None,
}) => (),
Ok(line) => return Some(line.try_into()),
Err(err) => return Some(Err(err)),
}
}
}
}

/// Individual entry in an `authorized_keys` file containing a single public key.
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
pub struct Entry<'a> {
/// Options field, if present.
pub options: Option<&'a str>,

/// Public key
pub public_key: PublicKey,
}

impl<'a> TryFrom<LineParser<'a>> for Entry<'a> {
type Error = Error;

fn try_from(line: LineParser<'a>) -> Result<Entry<'a>> {
let public_key = line
.public_key_str
.ok_or(Error::FormatEncoding)?
.parse::<PublicKey>()?;

Ok(Self {
options: line.options_str,
public_key,
})
}
}

/// Parser for an individual line in an `authorized_keys` file.
#[derive(Debug)]
struct LineParser<'a> {
/// Options field, if present.
options_str: Option<&'a str>,

/// Public key data, if present.
public_key_str: Option<&'a str>,
}

impl<'a> LineParser<'a> {
/// Parse the given line.
pub fn new(mut line: &'a str) -> Result<Self> {
// Strip comment, if present
if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) {
line = l;
}

// Trim trailing whitespace
line = line.trim_end();

if line.is_empty() {
return Ok(Self {
options_str: None,
public_key_str: None,
});
}

match line.matches(' ').count() {
1..=2 => Ok(Self {
options_str: None,
public_key_str: Some(line),
}),
3 => match line.split_once(' ') {
Some((options_str, public_key_str)) => Ok(Self {
options_str: Some(options_str),
public_key_str: Some(public_key_str),
}),
_ => Err(Error::FormatEncoding),
},
_ => Err(Error::FormatEncoding),
}
}
}
15 changes: 15 additions & 0 deletions ssh-key/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub enum Error {
/// Other format encoding errors.
FormatEncoding,

/// Input/output errors.
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
Io(std::io::ErrorKind),

/// Invalid length.
Length,

Expand All @@ -45,6 +50,8 @@ impl fmt::Display for Error {
#[cfg(feature = "ecdsa")]
Error::Ecdsa(err) => write!(f, "ECDSA encoding error: {}", err),
Error::FormatEncoding => f.write_str("format encoding error"),
#[cfg(feature = "std")]
Error::Io(err) => write!(f, "I/O error: {}", std::io::Error::from(*err)),
Error::Length => f.write_str("length invalid"),
Error::Overflow => f.write_str("internal overflow error"),
Error::Pem => f.write_str("PEM encoding error"),
Expand Down Expand Up @@ -106,3 +113,11 @@ impl From<sec1::Error> for Error {
Error::Ecdsa(err)
}
}

#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Error {
Error::Io(err.kind())
}
}
2 changes: 2 additions & 0 deletions ssh-key/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ extern crate alloc;
#[cfg(feature = "std")]
extern crate std;

pub mod authorized_keys;
pub mod private;
pub mod public;

Expand All @@ -126,6 +127,7 @@ mod mpint;

pub use crate::{
algorithm::{Algorithm, CipherAlg, EcdsaCurve, KdfAlg, KdfOptions},
authorized_keys::AuthorizedKeys,
error::{Error, Result},
private::PrivateKey,
public::PublicKey,
Expand Down
2 changes: 1 addition & 1 deletion ssh-key/src/public.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use alloc::{
};

/// SSH public key.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
pub struct PublicKey {
/// Key data.
pub key_data: KeyData,
Expand Down
35 changes: 35 additions & 0 deletions ssh-key/tests/authorized_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! Tests for parsing `authorized_keys` files.

#![cfg(all(feature = "ecdsa", feature = "std"))]

use ssh_key::AuthorizedKeys;

// TODO(tarcieri): test file permissions
#[test]
fn read_example_file() {
AuthorizedKeys::read_file("./tests/examples/authorized_keys", |mut authorized_keys| {
let entry1 = authorized_keys.next().unwrap()?;
assert_eq!(entry1.options, None);
assert_eq!(entry1.public_key.to_string(), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com");
assert_eq!(entry1.public_key.comment, "user1@example.com");

let entry2 = authorized_keys.next().unwrap()?;
assert_eq!(entry2.options, Some("command=\"/usr/bin/date\""));
assert_eq!(entry2.public_key.to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com");
assert_eq!(entry2.public_key.comment, "user2@example.com");

let entry3 = authorized_keys.next().unwrap()?;
assert_eq!(entry3.options, Some("environment=\"PATH=/bin:/usr/bin\""));
assert_eq!(entry3.public_key.to_string(), "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com");
assert_eq!(entry3.public_key.comment, "user3@example.com");

let entry4 = authorized_keys.next().unwrap()?;
assert_eq!(entry4.options, Some("from=\"10.0.0.?,*.example.com\",no-X11-forwarding"));
assert_eq!(entry4.public_key.to_string(), "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0WRHtxuxefSJhpIxGq4ibGFgwYnESPm8C3JFM88A1JJLoprenklrd7VJ+VH3Ov/bQwZwLyRU5dRmfR/SWTtIPWs7tToJVayKKDB+/qoXmM5ui/0CU2U4rCdQ6PdaCJdC7yFgpPL8WexjWN06+eSIKYz1AAXbx9rRv1iasslK/KUqtsqzVliagI6jl7FPO2GhRZMcso6LsZGgSxuYf/Lp0D/FcBU8GkeOo1Sx5xEt8H8bJcErtCe4Blb8JxcW6EXO3sReb4z+zcR07gumPgFITZ6hDA8sSNuvo/AlWg0IKTeZSwHHVknWdQqDJ0uczE837caBxyTZllDNIGkBjCIIOFzuTT76HfYc/7CTTGk07uaNkUFXKN79xDiFOX8JQ1ZZMZvGOTwWjuT9CqgdTvQRORbRWwOYv3MH8re9ykw3Ip6lrPifY7s6hOaAKry/nkGPMt40m1TdiW98MTIpooE7W+WXu96ax2l2OJvxX8QR7l+LFlKnkIEEJd/ItF1G22UmOjkVwNASTwza/hlY+8DoVvEmwum/nMgH2TwQT3bTQzF9s9DOJkH4d8p4Mw4gEDjNx0EgUFA91ysCAeUMQQyIvuR8HXXa+VcvhOOO5mmBcVhxJ3qUOJTyDBsT0932Zb4mNtkxdigoVxu+iiwk0vwtvKwGVDYdyMP5EAQeEIP1t0w== user4@example.com");
assert_eq!(entry4.public_key.comment, "user4@example.com");

assert_eq!(authorized_keys.next(), None);
Ok(())
})
.unwrap();
}
28 changes: 28 additions & 0 deletions ssh-key/tests/examples/authorized_keys
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Example authorized keys file
#
# - Comments in these files begin with `#`
# - They can also contain blank lines
# - Lines which are not blank each contain a single public key
# - Maximum line length is 8 kilobytes
#
# Public keys consist of the following space-separated fields:
#
# options, keytype, base64-encoded key, comment
#
# - The options field is optional.
# - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
# `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
# - The comment field is not used for anything (but may be convenient for the user to
# identify the key).

# Public key with no options
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com

# Public key which can only read the current date
command="/usr/bin/date" ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com

# Public key which ensures a certain environment is set
environment="PATH=/bin:/usr/bin" ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com

# Public key which can only be used from certain source addresses and disallows X11 forwarding
from="10.0.0.?,*.example.com",no-X11-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0WRHtxuxefSJhpIxGq4ibGFgwYnESPm8C3JFM88A1JJLoprenklrd7VJ+VH3Ov/bQwZwLyRU5dRmfR/SWTtIPWs7tToJVayKKDB+/qoXmM5ui/0CU2U4rCdQ6PdaCJdC7yFgpPL8WexjWN06+eSIKYz1AAXbx9rRv1iasslK/KUqtsqzVliagI6jl7FPO2GhRZMcso6LsZGgSxuYf/Lp0D/FcBU8GkeOo1Sx5xEt8H8bJcErtCe4Blb8JxcW6EXO3sReb4z+zcR07gumPgFITZ6hDA8sSNuvo/AlWg0IKTeZSwHHVknWdQqDJ0uczE837caBxyTZllDNIGkBjCIIOFzuTT76HfYc/7CTTGk07uaNkUFXKN79xDiFOX8JQ1ZZMZvGOTwWjuT9CqgdTvQRORbRWwOYv3MH8re9ykw3Ip6lrPifY7s6hOaAKry/nkGPMt40m1TdiW98MTIpooE7W+WXu96ax2l2OJvxX8QR7l+LFlKnkIEEJd/ItF1G22UmOjkVwNASTwza/hlY+8DoVvEmwum/nMgH2TwQT3bTQzF9s9DOJkH4d8p4Mw4gEDjNx0EgUFA91ysCAeUMQQyIvuR8HXXa+VcvhOOO5mmBcVhxJ3qUOJTyDBsT0932Zb4mNtkxdigoVxu+iiwk0vwtvKwGVDYdyMP5EAQeEIP1t0w== user4@example.com