Skip to content

Commit c7aaebd

Browse files
authored
ssh-key: non-UTF-8 comment support (#347)
OpenSSH private keys contain a comment field in the binary serialization encoded as an RFC4251 `string` type, which is described as follows: Arbitrary length binary string. Strings are allowed to contain arbitrary binary data, including null characters and 8-bit characters Previously this field was stored using Rust's `String` type which constrains the allowed encoding of the comments to UTF-8, leading to a failure to parse keys containing such comments. This has been changed to store a `Vec<u8>`, with helper methods `comment_str` and `comment_str_lossy` to obtain `&str` references, and `comment` bytes to get the raw value. The original `comment` methods have been deprecated. The lossy algorithm decodes as much of the string as possible as UTF-8. The public key serialization, which encodes the key as a one-line string, uses the lossy encoding of the comment so as to still remain `String`-compatible. If there's desire to correct this, it will require more substantial changes to the entire public key decoder/encoder. Fixes #331
1 parent 4aae71a commit c7aaebd

9 files changed

Lines changed: 161 additions & 58 deletions

File tree

ssh-key/src/ppk.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ impl PpkContainer {
446446
let keypair_data =
447447
decode_private_key_as(&mut private_key_cursor, public_key.clone(), ppk.algorithm)?;
448448

449-
public_key.comment = comment.unwrap_or_default();
449+
public_key.comment = comment.unwrap_or_default().into();
450450

451451
Ok(PpkContainer {
452452
public_key,

ssh-key/src/private.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ impl PrivateKey {
212212
///
213213
/// On `no_std` platforms, use `PrivateKey::from(key_data)` instead.
214214
#[cfg(feature = "alloc")]
215-
pub fn new(key_data: KeypairData, comment: impl Into<String>) -> Result<Self> {
215+
pub fn new(key_data: KeypairData, comment: impl Into<Vec<u8>>) -> Result<Self> {
216216
if key_data.is_encrypted() {
217217
return Err(Error::Encrypted);
218218
}
@@ -464,8 +464,44 @@ impl PrivateKey {
464464
}
465465

466466
/// Comment on the key (e.g. email address).
467+
#[cfg(feature = "alloc")]
468+
#[deprecated(
469+
since = "0.7.0",
470+
note = "please use `comment_bytes`, `comment_str`, or `comment_str_lossy` instead"
471+
)]
467472
pub fn comment(&self) -> &str {
468-
self.public_key.comment()
473+
self.comment_str_lossy()
474+
}
475+
476+
/// Comment on the key (e.g. email address).
477+
#[cfg(not(feature = "alloc"))]
478+
pub fn comment_bytes(&self) -> &[u8] {
479+
b""
480+
}
481+
482+
/// Comment on the key (e.g. email address).
483+
///
484+
/// Since comments can contain arbitrary binary data when decoded from a
485+
/// private key, this returns the raw bytes of the comment.
486+
#[cfg(feature = "alloc")]
487+
pub fn comment_bytes(&self) -> &[u8] {
488+
self.public_key.comment_bytes()
489+
}
490+
491+
/// Comment on the key (e.g. email address).
492+
///
493+
/// This returns a UTF-8 interpretation of the comment when valid.
494+
#[cfg(feature = "alloc")]
495+
pub fn comment_str(&self) -> core::result::Result<&str, str::Utf8Error> {
496+
self.public_key.comment_str()
497+
}
498+
499+
/// Comment on the key (e.g. email address).
500+
///
501+
/// This returns as much data as can be interpreted as valid UTF-8.
502+
#[cfg(feature = "alloc")]
503+
pub fn comment_str_lossy(&self) -> &str {
504+
self.public_key.comment_str_lossy()
469505
}
470506

471507
/// Cipher algorithm (a.k.a. `ciphername`).
@@ -539,7 +575,7 @@ impl PrivateKey {
539575

540576
/// Set the comment on the key.
541577
#[cfg(feature = "alloc")]
542-
pub fn set_comment(&mut self, comment: impl Into<String>) {
578+
pub fn set_comment(&mut self, comment: impl Into<Vec<u8>>) {
543579
self.public_key.set_comment(comment);
544580
}
545581

@@ -645,7 +681,7 @@ impl PrivateKey {
645681
checkint.encode(writer)?;
646682
checkint.encode(writer)?;
647683
self.key_data.encode(writer)?;
648-
self.comment().encode(writer)?;
684+
self.comment_bytes().encode(writer)?;
649685
writer.write(&PADDING_BYTES[..padding_len])?;
650686
Ok(())
651687
}
@@ -668,7 +704,7 @@ impl PrivateKey {
668704
[
669705
8, // 2 x uint32 checkints,
670706
self.key_data.encoded_len()?,
671-
self.comment().encoded_len()?,
707+
self.comment_bytes().encoded_len()?,
672708
]
673709
.checked_sum()
674710
}

ssh-key/src/public.rs

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pub use self::{ecdsa::EcdsaPublicKey, sk::SkEcdsaSha2NistP256};
3030
pub(crate) use self::ssh_format::SshFormat;
3131

3232
use crate::{Algorithm, Error, Fingerprint, HashAlg, Result};
33-
use core::str::FromStr;
33+
use core::str::{self, FromStr};
3434
use encoding::{Base64Reader, Decode, Reader};
3535

3636
#[cfg(feature = "alloc")]
@@ -93,16 +93,21 @@ pub struct PublicKey {
9393
pub(crate) key_data: KeyData,
9494

9595
/// Comment on the key (e.g. email address)
96+
///
97+
/// Note that when a [`PublicKey`] is serialized in a private key, the
98+
/// comment is encoded as an RFC4251 `string` which may contain arbitrary
99+
/// binary data, so `Vec<u8>` is used to store the comment to ensure keys
100+
/// containing such comments successfully round-trip.
96101
#[cfg(feature = "alloc")]
97-
pub(crate) comment: String,
102+
pub(crate) comment: Vec<u8>,
98103
}
99104

100105
impl PublicKey {
101106
/// Create a new public key with the given comment.
102107
///
103108
/// On `no_std` platforms, use `PublicKey::from(key_data)` instead.
104109
#[cfg(feature = "alloc")]
105-
pub fn new(key_data: KeyData, comment: impl Into<String>) -> Self {
110+
pub fn new(key_data: KeyData, comment: impl Into<Vec<u8>>) -> Self {
106111
Self {
107112
key_data,
108113
comment: comment.into(),
@@ -129,7 +134,7 @@ impl PublicKey {
129134
let public_key = Self {
130135
key_data,
131136
#[cfg(feature = "alloc")]
132-
comment: encapsulation.comment.to_owned(),
137+
comment: encapsulation.comment.to_owned().into(),
133138
};
134139

135140
Ok(reader.finish(public_key)?)
@@ -144,19 +149,23 @@ impl PublicKey {
144149

145150
/// Encode OpenSSH-formatted public key.
146151
pub fn encode_openssh<'o>(&self, out: &'o mut [u8]) -> Result<&'o str> {
147-
SshFormat::encode(
148-
self.algorithm().as_str(),
149-
&self.key_data,
150-
self.comment(),
151-
out,
152-
)
152+
#[cfg(not(feature = "alloc"))]
153+
let comment = "";
154+
#[cfg(feature = "alloc")]
155+
let comment = self.comment_str_lossy();
156+
157+
SshFormat::encode(self.algorithm().as_str(), &self.key_data, comment, out)
153158
}
154159

155160
/// Encode an OpenSSH-formatted public key, allocating a [`String`] for
156161
/// the result.
157162
#[cfg(feature = "alloc")]
158163
pub fn to_openssh(&self) -> Result<String> {
159-
SshFormat::encode_string(self.algorithm().as_str(), &self.key_data, self.comment())
164+
SshFormat::encode_string(
165+
self.algorithm().as_str(),
166+
&self.key_data,
167+
self.comment_str_lossy(),
168+
)
160169
}
161170

162171
/// Serialize SSH public key as raw bytes.
@@ -249,17 +258,48 @@ impl PublicKey {
249258
}
250259

251260
/// Comment on the key (e.g. email address).
252-
#[cfg(not(feature = "alloc"))]
261+
///
262+
/// This is a deprecated alias for [`PublicKey::comment_str_lossy`].
263+
#[cfg(feature = "alloc")]
264+
#[deprecated(
265+
since = "0.7.0",
266+
note = "please use `comment_bytes`, `comment_str`, or `comment_str_lossy` instead"
267+
)]
253268
pub fn comment(&self) -> &str {
254-
""
269+
self.comment_str_lossy()
255270
}
256271

257272
/// Comment on the key (e.g. email address).
273+
///
274+
/// Since comments can contain arbitrary binary data when decoded from a
275+
/// private key, this returns the raw bytes of the comment.
258276
#[cfg(feature = "alloc")]
259-
pub fn comment(&self) -> &str {
277+
pub fn comment_bytes(&self) -> &[u8] {
260278
&self.comment
261279
}
262280

281+
/// Comment on the key (e.g. email address).
282+
///
283+
/// This returns a UTF-8 interpretation of the comment when valid.
284+
#[cfg(feature = "alloc")]
285+
pub fn comment_str(&self) -> core::result::Result<&str, str::Utf8Error> {
286+
str::from_utf8(&self.comment)
287+
}
288+
289+
/// Comment on the key (e.g. email address).
290+
///
291+
/// This returns as much data as can be interpreted as valid UTF-8.
292+
#[cfg(feature = "alloc")]
293+
pub fn comment_str_lossy(&self) -> &str {
294+
for i in (1..=self.comment.len()).rev() {
295+
if let Ok(s) = str::from_utf8(&self.comment[..i]) {
296+
return s;
297+
}
298+
}
299+
300+
""
301+
}
302+
263303
/// Public key data.
264304
pub fn key_data(&self) -> &KeyData {
265305
&self.key_data
@@ -274,7 +314,7 @@ impl PublicKey {
274314

275315
/// Set the comment on the key.
276316
#[cfg(feature = "alloc")]
277-
pub fn set_comment(&mut self, comment: impl Into<String>) {
317+
pub fn set_comment(&mut self, comment: impl Into<Vec<u8>>) {
278318
self.comment = comment.into();
279319
}
280320

@@ -290,7 +330,7 @@ impl PublicKey {
290330
/// Decode comment (e.g. email address)
291331
#[cfg(feature = "alloc")]
292332
pub(crate) fn decode_comment(&mut self, reader: &mut impl Reader) -> Result<()> {
293-
self.comment = String::decode(reader)?;
333+
self.comment = Vec::decode(reader)?;
294334
Ok(())
295335
}
296336
}
@@ -300,7 +340,7 @@ impl From<KeyData> for PublicKey {
300340
PublicKey {
301341
key_data,
302342
#[cfg(feature = "alloc")]
303-
comment: String::new(),
343+
comment: Vec::new(),
304344
}
305345
}
306346
}

ssh-key/tests/authorized_keys.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ fn read_example_file() {
1515
authorized_keys[0].public_key().to_string(),
1616
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti"
1717
);
18-
assert_eq!(authorized_keys[0].public_key().comment(), "");
18+
assert_eq!(authorized_keys[0].public_key().comment_bytes(), b"");
1919

2020
assert_eq!(
2121
authorized_keys[1].config_opts().to_string(),
2222
"command=\"/usr/bin/date\""
2323
);
2424
assert_eq!(authorized_keys[1].public_key().to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com");
2525
assert_eq!(
26-
authorized_keys[1].public_key().comment(),
27-
"user2@example.com"
26+
authorized_keys[1].public_key().comment_bytes(),
27+
b"user2@example.com"
2828
);
2929

3030
assert_eq!(
@@ -33,8 +33,8 @@ fn read_example_file() {
3333
);
3434
assert_eq!(authorized_keys[2].public_key().to_string(), "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com");
3535
assert_eq!(
36-
authorized_keys[2].public_key().comment(),
37-
"user3@example.com"
36+
authorized_keys[2].public_key().comment_bytes(),
37+
b"user3@example.com"
3838
);
3939

4040
assert_eq!(
@@ -43,13 +43,13 @@ fn read_example_file() {
4343
);
4444
assert_eq!(authorized_keys[3].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");
4545
assert_eq!(
46-
authorized_keys[3].public_key().comment(),
47-
"user4@example.com"
46+
authorized_keys[3].public_key().comment_bytes(),
47+
b"user4@example.com"
4848
);
4949

5050
assert_eq!(authorized_keys[4].public_key().to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN76zuqnjypL54/w4763l7q1Sn3IBYHptJ5wcYfEWkzeNTvpexr05Z18m2yPT2SWRd1JJ8Aj5TYidG9MdSS5J78= hello world this is a long comment");
5151
assert_eq!(
52-
authorized_keys[4].public_key().comment(),
53-
"hello world this is a long comment"
52+
authorized_keys[4].public_key().comment_bytes(),
53+
b"hello world this is a long comment"
5454
);
5555
}

ssh-key/tests/dot_ssh.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ fn path_round_trip() {
2020
#[test]
2121
fn private_keys() {
2222
let dot_ssh = dot_ssh();
23-
assert_eq!(dot_ssh.private_keys().unwrap().count(), 21);
23+
assert_eq!(dot_ssh.private_keys().unwrap().count(), 22);
2424
}
2525

2626
#[test]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3+
QyNTUxOQAAACD2TvUAi4NJBWXBvDkzbLiIjgjeMzhTz9cUleJn2zRWKAAAAJi1GedGtRnn
4+
RgAAAAtzc2gtZWQyNTUxOQAAACD2TvUAi4NJBWXBvDkzbLiIjgjeMzhTz9cUleJn2zRWKA
5+
AAAEDBi49uVXZzDhN8JohiYkBdezFWbCAw6iCS2JRA4J0ujfZO9QCLg0kFZcG8OTNsuIiO
6+
CN4zOFPP1xSV4mfbNFYoAAAAFHN0YXJfQLLcyPHI8cjxtcS158TUAQ==
7+
-----END OPENSSH PRIVATE KEY-----

ssh-key/tests/known_hosts.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ fn read_example_file() {
1919
known_hosts[0].public_key().to_string(),
2020
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti"
2121
);
22-
assert_eq!(known_hosts[0].public_key().comment(), "");
22+
assert_eq!(known_hosts[0].public_key().comment_bytes(), b"");
2323

2424
assert_eq!(known_hosts[1].marker(), None);
2525
assert_eq!(
@@ -31,7 +31,7 @@ fn read_example_file() {
3131
])
3232
);
3333
assert_eq!(known_hosts[1].public_key().to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= example.com");
34-
assert_eq!(known_hosts[1].public_key().comment(), "example.com");
34+
assert_eq!(known_hosts[1].public_key().comment_bytes(), b"example.com");
3535

3636
assert_eq!(known_hosts[2].marker(), Some(&Marker::Revoked));
3737
assert_eq!(
@@ -48,7 +48,7 @@ fn read_example_file() {
4848
}
4949
);
5050
assert_eq!(known_hosts[2].public_key().to_string(), "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA==");
51-
assert_eq!(known_hosts[2].public_key().comment(), "");
51+
assert_eq!(known_hosts[2].public_key().comment_bytes(), b"");
5252

5353
assert_eq!(known_hosts[3].marker(), Some(&Marker::CertAuthority));
5454
assert_eq!(
@@ -57,7 +57,7 @@ fn read_example_file() {
5757
);
5858
assert_eq!(known_hosts[3].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== authority@example.com");
5959
assert_eq!(
60-
known_hosts[3].public_key().comment(),
61-
"authority@example.com"
60+
known_hosts[3].public_key().comment_bytes(),
61+
b"authority@example.com"
6262
);
6363
}

0 commit comments

Comments
 (0)