Skip to content

Commit ff8a2fa

Browse files
committed
ssh-key: initial PublicKey encoder w\ Ed25519 support
Adds initial support for encoding `PublicKey` data in OpenSSH format. Uses the new buffered `base64ct::Encoder` type. Ed25519 keys are supported with a `no_std`-friendly profile.
1 parent 6260f8b commit ff8a2fa

7 files changed

Lines changed: 237 additions & 13 deletions

File tree

ssh-key/src/algorithm.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Algorithm support.
22
33
use crate::{
4-
base64::{self, Decode},
4+
base64::{self, Decode, Encode},
55
Error, Result,
66
};
77
use core::{fmt, str};
@@ -109,6 +109,16 @@ impl Decode for Algorithm {
109109
}
110110
}
111111

112+
impl Encode for Algorithm {
113+
fn encoded_len(&self) -> Result<usize> {
114+
Ok(4 + self.as_str().len())
115+
}
116+
117+
fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> {
118+
encoder.encode_str(self.as_str())
119+
}
120+
}
121+
112122
impl fmt::Display for Algorithm {
113123
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114124
f.write_str(self.as_str())

ssh-key/src/base64.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl<'i> Decoder<'i> {
5757
Ok(buf[0])
5858
}
5959

60-
/// Decodes a `uint32` as described in [RFC4251 § 5]:
60+
/// Decode a `uint32` as described in [RFC4251 § 5]:
6161
///
6262
/// > Represents a 32-bit unsigned integer. Stored as four bytes in the
6363
/// > order of decreasing significance (network byte order).
@@ -113,7 +113,7 @@ impl<'i> Decoder<'i> {
113113
Ok(result)
114114
}
115115

116-
/// Decodes a `string` as described in [RFC4251 § 5]:
116+
/// Decode a `string` as described in [RFC4251 § 5]:
117117
///
118118
/// > Arbitrary length binary string. Strings are allowed to contain
119119
/// > arbitrary binary data, including null characters and 8-bit
@@ -146,9 +146,97 @@ impl<'i> Decoder<'i> {
146146
}
147147
}
148148

149+
/// Encoder trait.
150+
pub(crate) trait Encode: Sized {
151+
/// Get the length of this type encoded in bytes, prior to Base64 encoding.
152+
fn encoded_len(&self) -> Result<usize>;
153+
154+
/// Attempt to encode a value of this type using the provided [`Encoder`].
155+
fn encode(&self, encoder: &mut Encoder<'_>) -> Result<()>;
156+
}
157+
158+
/// Stateful Base64 encoder.
159+
pub(crate) struct Encoder<'o> {
160+
inner: base64ct::Encoder<'o, base64ct::Base64>,
161+
}
162+
163+
impl<'o> Encoder<'o> {
164+
/// Create a new decoder for a byte slice containing contiguous
165+
/// (non-newline-delimited) Base64-encoded data.
166+
pub(crate) fn new(buffer: &'o mut [u8]) -> Result<Self> {
167+
Ok(Self {
168+
inner: base64ct::Encoder::new(buffer)?,
169+
})
170+
}
171+
172+
/// Encode the given byte slice as Base64.
173+
pub(crate) fn encode(&mut self, bytes: &[u8]) -> Result<()> {
174+
Ok(self.inner.encode(bytes)?)
175+
}
176+
177+
/// Encode a `uint32` as described in [RFC4251 § 5]:
178+
///
179+
/// > Represents a 32-bit unsigned integer. Stored as four bytes in the
180+
/// > order of decreasing significance (network byte order).
181+
/// > For example: the value 699921578 (0x29b7f4aa) is stored as 29 b7 f4 aa.
182+
///
183+
/// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5
184+
pub(crate) fn encode_u32(&mut self, num: u32) -> Result<()> {
185+
self.encode(&num.to_be_bytes())
186+
}
187+
188+
/// Encode a `usize` as a `uint32` as described in [RFC4251 § 5].
189+
///
190+
/// Uses [`Encoder::encode_u32`] after converting from a `usize`, handling
191+
/// potential overflow if `usize` is bigger than `u32`.
192+
///
193+
/// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5
194+
pub(crate) fn encode_usize(&mut self, num: usize) -> Result<()> {
195+
self.encode_u32(u32::try_from(num)?)
196+
}
197+
198+
/// Encodes `[u8]` into `byte[n]` as described in [RFC4251 § 5]:
199+
///
200+
/// > A byte represents an arbitrary 8-bit value (octet). Fixed length
201+
/// > data is sometimes represented as an array of bytes, written
202+
/// > byte[n], where n is the number of bytes in the array.
203+
///
204+
/// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5
205+
pub(crate) fn encode_byte_slice(&mut self, bytes: &[u8]) -> Result<()> {
206+
self.encode_usize(bytes.len())?;
207+
self.encode(bytes)
208+
}
209+
210+
/// Encode a `string` as described in [RFC4251 § 5]:
211+
///
212+
/// > Arbitrary length binary string. Strings are allowed to contain
213+
/// > arbitrary binary data, including null characters and 8-bit
214+
/// > characters. They are stored as a uint32 containing its length
215+
/// > (number of bytes that follow) and zero (= empty string) or more
216+
/// > bytes that are the value of the string. Terminating null
217+
/// > characters are not used.
218+
/// >
219+
/// > Strings are also used to store text. In that case, US-ASCII is
220+
/// > used for internal names, and ISO-10646 UTF-8 for text that might
221+
/// > be displayed to the user. The terminating null character SHOULD
222+
/// > NOT normally be stored in the string. For example: the US-ASCII
223+
/// > string "testing" is represented as 00 00 00 07 t e s t i n g. The
224+
/// > UTF-8 mapping does not alter the encoding of US-ASCII characters.
225+
///
226+
/// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5
227+
pub(crate) fn encode_str(&mut self, s: &str) -> Result<()> {
228+
self.encode_byte_slice(s.as_bytes())
229+
}
230+
231+
/// Finish encoding, returning the encoded Base64 as a `str`.
232+
pub(crate) fn finish(self) -> Result<&'o str> {
233+
Ok(self.inner.finish()?)
234+
}
235+
}
236+
149237
#[cfg(test)]
150238
mod tests {
151-
use super::Decoder;
239+
use super::{Decoder, Encoder};
152240

153241
/// From `id_ecdsa_p256.pub`
154242
const EXAMPLE_BASE64: &str =
@@ -168,4 +256,12 @@ mod tests {
168256
let decoded = decoder.decode_into(&mut buf).unwrap();
169257
assert_eq!(EXAMPLE_BIN, decoded);
170258
}
259+
260+
#[test]
261+
fn encode() {
262+
let mut buffer = [0u8; EXAMPLE_BASE64.len()];
263+
let mut encoder = Encoder::new(&mut buffer).unwrap();
264+
encoder.encode(EXAMPLE_BIN).unwrap();
265+
assert_eq!(EXAMPLE_BASE64, encoder.finish().unwrap());
266+
}
171267
}

ssh-key/src/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ impl From<core::str::Utf8Error> for Error {
8585
}
8686
}
8787

88+
#[cfg(feature = "alloc")]
89+
#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))]
90+
impl From<alloc::string::FromUtf8Error> for Error {
91+
fn from(_: alloc::string::FromUtf8Error) -> Error {
92+
Error::CharacterEncoding
93+
}
94+
}
95+
8896
#[cfg(feature = "ecdsa")]
8997
#[cfg_attr(docsrs, doc(cfg(feature = "ecdsa")))]
9098
impl From<sec1::Error> for Error {

ssh-key/src/public.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ pub use self::ed25519::Ed25519PublicKey;
1818
pub use self::{dsa::DsaPublicKey, rsa::RsaPublicKey};
1919

2020
use crate::{
21-
base64::{self, Decode},
21+
base64::{self, Decode, Encode},
2222
Algorithm, Error, Result,
2323
};
2424
use core::str::FromStr;
2525

2626
#[cfg(feature = "alloc")]
27-
use alloc::{borrow::ToOwned, string::String};
27+
use alloc::{
28+
borrow::ToOwned,
29+
string::{String, ToString},
30+
};
2831

2932
/// SSH public key.
3033
#[derive(Clone, Debug)]
@@ -67,6 +70,33 @@ impl PublicKey {
6770
})
6871
}
6972

73+
/// Encode this public key as a OpenSSH-formatted public key.
74+
pub fn encode_openssh<'o>(&self, out: &'o mut [u8]) -> Result<&'o str> {
75+
#[cfg(not(feature = "alloc"))]
76+
let comment = "";
77+
#[cfg(feature = "alloc")]
78+
let comment = &self.comment;
79+
80+
openssh::Encapsulation::encode(out, self.algorithm().as_str(), comment, |encoder| {
81+
self.key_data.encode(encoder)
82+
})
83+
}
84+
85+
/// Encode this public key as an OpenSSH-formatted public key, allocating a
86+
/// [`String`] for the result.
87+
#[cfg(feature = "alloc")]
88+
pub fn to_openssh(&self) -> Result<String> {
89+
let encoded_len = 2
90+
+ self.algorithm().as_str().len()
91+
+ (self.key_data.encoded_len()? * 4 / 3)
92+
+ self.comment.len();
93+
94+
let mut buf = vec![0u8; encoded_len];
95+
let actual_len = self.encode_openssh(&mut buf)?.len();
96+
buf.truncate(actual_len);
97+
Ok(String::from_utf8(buf)?)
98+
}
99+
70100
/// Get the digital signature [`Algorithm`] used by this key.
71101
pub fn algorithm(&self) -> Algorithm {
72102
self.key_data.algorithm()
@@ -81,6 +111,13 @@ impl FromStr for PublicKey {
81111
}
82112
}
83113

114+
#[cfg(feature = "alloc")]
115+
impl ToString for PublicKey {
116+
fn to_string(&self) -> String {
117+
self.to_openssh().expect("SSH public key encoding error")
118+
}
119+
}
120+
84121
/// Public key data.
85122
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
86123
#[non_exhaustive]
@@ -202,3 +239,22 @@ impl Decode for KeyData {
202239
}
203240
}
204241
}
242+
243+
impl Encode for KeyData {
244+
fn encoded_len(&self) -> Result<usize> {
245+
let alg_len = self.algorithm().encoded_len()?;
246+
let key_len = match self {
247+
Self::Ed25519(key) => key.encoded_len()?,
248+
_ => return Err(Error::Algorithm),
249+
};
250+
Ok(alg_len + key_len)
251+
}
252+
253+
fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> {
254+
self.algorithm().encode(encoder)?;
255+
match self {
256+
Self::Ed25519(key) => key.encode(encoder),
257+
_ => Err(Error::Algorithm),
258+
}
259+
}
260+
}

ssh-key/src/public/ed25519.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Edwards Digital Signature Algorithm (EdDSA) over Curve25519.
44
55
use crate::{
6-
base64::{self, Decode},
6+
base64::{self, Decode, Encode},
77
Error, Result,
88
};
99
use core::fmt;
@@ -37,6 +37,16 @@ impl Decode for Ed25519PublicKey {
3737
}
3838
}
3939

40+
impl Encode for Ed25519PublicKey {
41+
fn encoded_len(&self) -> Result<usize> {
42+
Ok(4 + Self::BYTE_SIZE)
43+
}
44+
45+
fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> {
46+
encoder.encode_byte_slice(self.as_ref())
47+
}
48+
}
49+
4050
impl fmt::Display for Ed25519PublicKey {
4151
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4252
write!(f, "{:X}", self)

ssh-key/src/public/openssh.rs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
//! ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user@example.com
1313
//! ```
1414
15-
use crate::{Error, Result};
15+
use crate::{base64, Error, Result};
1616
use core::str;
1717

1818
/// OpenSSH public key encapsulation parser.
@@ -31,8 +31,8 @@ pub(crate) struct Encapsulation<'a> {
3131
impl<'a> Encapsulation<'a> {
3232
/// Parse the given binary data.
3333
pub(super) fn decode(mut bytes: &'a [u8]) -> Result<Self> {
34-
let algorithm_id = parse_segment_str(&mut bytes)?;
35-
let base64_data = parse_segment(&mut bytes)?;
34+
let algorithm_id = decode_segment_str(&mut bytes)?;
35+
let base64_data = decode_segment(&mut bytes)?;
3636
let comment = str::from_utf8(bytes)
3737
.map_err(|_| Error::CharacterEncoding)?
3838
.trim_end();
@@ -48,10 +48,34 @@ impl<'a> Encapsulation<'a> {
4848
comment,
4949
})
5050
}
51+
52+
/// Encode data with OpenSSH public key encapsulation.
53+
pub(super) fn encode<'o, F>(
54+
out: &'o mut [u8],
55+
algorithm_id: &str,
56+
comment: &str,
57+
f: F,
58+
) -> Result<&'o str>
59+
where
60+
F: FnOnce(&mut base64::Encoder<'_>) -> Result<()>,
61+
{
62+
let mut offset = 0;
63+
encode_str(out, &mut offset, algorithm_id)?;
64+
encode_str(out, &mut offset, " ")?;
65+
66+
let mut encoder = base64::Encoder::new(&mut out[offset..])?;
67+
f(&mut encoder)?;
68+
let base64_len = encoder.finish()?.len();
69+
70+
offset += base64_len;
71+
encode_str(out, &mut offset, " ")?;
72+
encode_str(out, &mut offset, comment)?;
73+
Ok(str::from_utf8(&out[..offset])?)
74+
}
5175
}
5276

5377
/// Parse a segment of the public key.
54-
fn parse_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> {
78+
fn decode_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> {
5579
let start = *bytes;
5680
let mut len = 0;
5781

@@ -81,8 +105,21 @@ fn parse_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> {
81105
}
82106

83107
/// Parse a segment of the public key as a `&str`.
84-
fn parse_segment_str<'a>(bytes: &mut &'a [u8]) -> Result<&'a str> {
85-
str::from_utf8(parse_segment(bytes)?).map_err(|_| Error::CharacterEncoding)
108+
fn decode_segment_str<'a>(bytes: &mut &'a [u8]) -> Result<&'a str> {
109+
str::from_utf8(decode_segment(bytes)?).map_err(|_| Error::CharacterEncoding)
110+
}
111+
112+
/// Encode a segment of the public key.
113+
fn encode_str(out: &mut [u8], offset: &mut usize, s: &str) -> Result<()> {
114+
let bytes = s.as_bytes();
115+
116+
if *offset + bytes.len() > out.len() {
117+
return Err(Error::Length);
118+
}
119+
120+
out[*offset..][..bytes.len()].copy_from_slice(bytes);
121+
*offset += bytes.len();
122+
Ok(())
86123
}
87124

88125
#[cfg(test)]

ssh-key/tests/public_key.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,10 @@ fn decode_rsa_4096_openssh() {
212212

213213
assert_eq!("user@example.com", ossh_key.comment);
214214
}
215+
216+
#[cfg(feature = "alloc")]
217+
#[test]
218+
fn encode_ed25519_openssh() {
219+
let ossh_key = PublicKey::from_openssh(OSSH_ED25519_EXAMPLE).unwrap();
220+
assert_eq!(OSSH_ED25519_EXAMPLE.trim_end(), &ossh_key.to_string())
221+
}

0 commit comments

Comments
 (0)