Skip to content

Commit 67551d0

Browse files
authored
ssh-key: AuthorizedKeys parser (#452)
Adds a parser for `authorized_keys` files which contain a list of SSH public keys each predicated by option flags.
1 parent 59b0212 commit 67551d0

8 files changed

Lines changed: 232 additions & 4 deletions

File tree

ssh-key/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ name = "ssh-key"
33
version = "0.3.0-pre" # Also update html_root_url in lib.rs when bumping this
44
description = """
55
Pure Rust implementation of SSH key file format decoders/encoders as described
6-
in RFC4253 and RFC4716 as well as the OpenSSH key formats. Supports "heapless"
7-
`no_std` embedded targets with an optional `alloc` feature (Ed25519 and ECDSA only)
6+
in RFC4253 and RFC4716 as well as the OpenSSH key formats and `authorized_keys`.
7+
Supports "heapless" `no_std` embedded targets with an optional `alloc` feature.
88
"""
99
authors = ["RustCrypto Developers"]
1010
license = "Apache-2.0 OR MIT"

ssh-key/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
## About
1313

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

1718
## Features
1819

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

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

3538
## Minimum Supported Rust Version
3639

ssh-key/src/authorized_keys.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Parser for `AuthorizedKeysFile`-formatted data.
2+
3+
use crate::{Error, PublicKey, Result};
4+
5+
#[cfg(feature = "std")]
6+
use std::{fs, path::Path};
7+
8+
/// Character that begins a comment
9+
const COMMENT_DELIMITER: char = '#';
10+
11+
/// Parser for `AuthorizedKeysFile`-formatted data, typically found in
12+
/// `~/.ssh/authorized_keys`.
13+
///
14+
/// For a full description of the format, see:
15+
/// <https://man7.org/linux/man-pages/man8/sshd.8.html#AUTHORIZED_KEYS_FILE_FORMAT>
16+
///
17+
/// Each line of the file consists of a single public key. Blank lines are ignored.
18+
///
19+
/// Public keys consist of the following space-separated fields:
20+
///
21+
/// ```text
22+
/// options, keytype, base64-encoded key, comment
23+
/// ```
24+
///
25+
/// - The options field is optional.
26+
/// - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
27+
/// `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
28+
/// - The comment field is not used for anything (but may be convenient for the user to identify
29+
/// the key).
30+
pub struct AuthorizedKeys<'a> {
31+
/// Lines of the file being iterated over
32+
lines: core::str::Lines<'a>,
33+
}
34+
35+
impl<'a> AuthorizedKeys<'a> {
36+
/// Create a new parser for the given input buffer.
37+
pub fn new(input: &'a str) -> Self {
38+
Self {
39+
lines: input.lines(),
40+
}
41+
}
42+
43+
/// Read a file from the filesystem, calling the given closure with an
44+
/// [`AuthorizedKeys`] parser which operates over a temporary buffer.
45+
#[cfg(feature = "std")]
46+
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
47+
pub fn read_file<T, F>(path: impl AsRef<Path>, f: F) -> Result<T>
48+
where
49+
F: FnOnce(AuthorizedKeys<'_>) -> Result<T>,
50+
{
51+
// TODO(tarcieri): permissions checks
52+
let input = fs::read_to_string(path)?;
53+
f(AuthorizedKeys::new(&input))
54+
}
55+
}
56+
57+
impl<'a> Iterator for AuthorizedKeys<'a> {
58+
type Item = Result<Entry<'a>>;
59+
60+
fn next(&mut self) -> Option<Result<Entry<'a>>> {
61+
loop {
62+
let result = LineParser::new(self.lines.next()?);
63+
64+
match result {
65+
Ok(LineParser {
66+
options_str: None,
67+
public_key_str: None,
68+
}) => (),
69+
Ok(line) => return Some(line.try_into()),
70+
Err(err) => return Some(Err(err)),
71+
}
72+
}
73+
}
74+
}
75+
76+
/// Individual entry in an `authorized_keys` file containing a single public key.
77+
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
78+
pub struct Entry<'a> {
79+
/// Options field, if present.
80+
pub options: Option<&'a str>,
81+
82+
/// Public key
83+
pub public_key: PublicKey,
84+
}
85+
86+
impl<'a> TryFrom<LineParser<'a>> for Entry<'a> {
87+
type Error = Error;
88+
89+
fn try_from(line: LineParser<'a>) -> Result<Entry<'a>> {
90+
let public_key = line
91+
.public_key_str
92+
.ok_or(Error::FormatEncoding)?
93+
.parse::<PublicKey>()?;
94+
95+
Ok(Self {
96+
options: line.options_str,
97+
public_key,
98+
})
99+
}
100+
}
101+
102+
/// Parser for an individual line in an `authorized_keys` file.
103+
#[derive(Debug)]
104+
struct LineParser<'a> {
105+
/// Options field, if present.
106+
options_str: Option<&'a str>,
107+
108+
/// Public key data, if present.
109+
public_key_str: Option<&'a str>,
110+
}
111+
112+
impl<'a> LineParser<'a> {
113+
/// Parse the given line.
114+
pub fn new(mut line: &'a str) -> Result<Self> {
115+
// Strip comment, if present
116+
if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) {
117+
line = l;
118+
}
119+
120+
// Trim trailing whitespace
121+
line = line.trim_end();
122+
123+
if line.is_empty() {
124+
return Ok(Self {
125+
options_str: None,
126+
public_key_str: None,
127+
});
128+
}
129+
130+
match line.matches(' ').count() {
131+
1..=2 => Ok(Self {
132+
options_str: None,
133+
public_key_str: Some(line),
134+
}),
135+
3 => match line.split_once(' ') {
136+
Some((options_str, public_key_str)) => Ok(Self {
137+
options_str: Some(options_str),
138+
public_key_str: Some(public_key_str),
139+
}),
140+
_ => Err(Error::FormatEncoding),
141+
},
142+
_ => Err(Error::FormatEncoding),
143+
}
144+
}
145+
}

ssh-key/src/error.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ pub enum Error {
2626
/// Other format encoding errors.
2727
FormatEncoding,
2828

29+
/// Input/output errors.
30+
#[cfg(feature = "std")]
31+
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
32+
Io(std::io::ErrorKind),
33+
2934
/// Invalid length.
3035
Length,
3136

@@ -45,6 +50,8 @@ impl fmt::Display for Error {
4550
#[cfg(feature = "ecdsa")]
4651
Error::Ecdsa(err) => write!(f, "ECDSA encoding error: {}", err),
4752
Error::FormatEncoding => f.write_str("format encoding error"),
53+
#[cfg(feature = "std")]
54+
Error::Io(err) => write!(f, "I/O error: {}", std::io::Error::from(*err)),
4855
Error::Length => f.write_str("length invalid"),
4956
Error::Overflow => f.write_str("internal overflow error"),
5057
Error::Pem => f.write_str("PEM encoding error"),
@@ -106,3 +113,11 @@ impl From<sec1::Error> for Error {
106113
Error::Ecdsa(err)
107114
}
108115
}
116+
117+
#[cfg(feature = "std")]
118+
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
119+
impl From<std::io::Error> for Error {
120+
fn from(err: std::io::Error) -> Error {
121+
Error::Io(err.kind())
122+
}
123+
}

ssh-key/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ extern crate alloc;
114114
#[cfg(feature = "std")]
115115
extern crate std;
116116

117+
pub mod authorized_keys;
117118
pub mod private;
118119
pub mod public;
119120

@@ -126,6 +127,7 @@ mod mpint;
126127

127128
pub use crate::{
128129
algorithm::{Algorithm, CipherAlg, EcdsaCurve, KdfAlg, KdfOptions},
130+
authorized_keys::AuthorizedKeys,
129131
error::{Error, Result},
130132
private::PrivateKey,
131133
public::PublicKey,

ssh-key/src/public.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use alloc::{
3030
};
3131

3232
/// SSH public key.
33-
#[derive(Clone, Debug)]
33+
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
3434
pub struct PublicKey {
3535
/// Key data.
3636
pub key_data: KeyData,

ssh-key/tests/authorized_keys.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//! Tests for parsing `authorized_keys` files.
2+
3+
#![cfg(all(feature = "ecdsa", feature = "std"))]
4+
5+
use ssh_key::AuthorizedKeys;
6+
7+
// TODO(tarcieri): test file permissions
8+
#[test]
9+
fn read_example_file() {
10+
AuthorizedKeys::read_file("./tests/examples/authorized_keys", |mut authorized_keys| {
11+
let entry1 = authorized_keys.next().unwrap()?;
12+
assert_eq!(entry1.options, None);
13+
assert_eq!(entry1.public_key.to_string(), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com");
14+
assert_eq!(entry1.public_key.comment, "user1@example.com");
15+
16+
let entry2 = authorized_keys.next().unwrap()?;
17+
assert_eq!(entry2.options, Some("command=\"/usr/bin/date\""));
18+
assert_eq!(entry2.public_key.to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com");
19+
assert_eq!(entry2.public_key.comment, "user2@example.com");
20+
21+
let entry3 = authorized_keys.next().unwrap()?;
22+
assert_eq!(entry3.options, Some("environment=\"PATH=/bin:/usr/bin\""));
23+
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");
24+
assert_eq!(entry3.public_key.comment, "user3@example.com");
25+
26+
let entry4 = authorized_keys.next().unwrap()?;
27+
assert_eq!(entry4.options, Some("from=\"10.0.0.?,*.example.com\",no-X11-forwarding"));
28+
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");
29+
assert_eq!(entry4.public_key.comment, "user4@example.com");
30+
31+
assert_eq!(authorized_keys.next(), None);
32+
Ok(())
33+
})
34+
.unwrap();
35+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Example authorized keys file
2+
#
3+
# - Comments in these files begin with `#`
4+
# - They can also contain blank lines
5+
# - Lines which are not blank each contain a single public key
6+
# - Maximum line length is 8 kilobytes
7+
#
8+
# Public keys consist of the following space-separated fields:
9+
#
10+
# options, keytype, base64-encoded key, comment
11+
#
12+
# - The options field is optional.
13+
# - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
14+
# `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
15+
# - The comment field is not used for anything (but may be convenient for the user to
16+
# identify the key).
17+
18+
# Public key with no options
19+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com
20+
21+
# Public key which can only read the current date
22+
command="/usr/bin/date" ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com
23+
24+
# Public key which ensures a certain environment is set
25+
environment="PATH=/bin:/usr/bin" ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com
26+
27+
# Public key which can only be used from certain source addresses and disallows X11 forwarding
28+
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

0 commit comments

Comments
 (0)