diff --git a/ssh-key/src/algorithm.rs b/ssh-key/src/algorithm.rs index 11739ccb6..ea58ff570 100644 --- a/ssh-key/src/algorithm.rs +++ b/ssh-key/src/algorithm.rs @@ -1,7 +1,7 @@ //! Algorithm support. use crate::{ - base64::{self, Decode}, + base64::{self, Decode, Encode}, Error, Result, }; use core::{fmt, str}; @@ -109,6 +109,16 @@ impl Decode for Algorithm { } } +impl Encode for Algorithm { + fn encoded_len(&self) -> Result { + Ok(4 + self.as_str().len()) + } + + fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> { + encoder.encode_str(self.as_str()) + } +} + impl fmt::Display for Algorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) diff --git a/ssh-key/src/base64.rs b/ssh-key/src/base64.rs index 7699b46a2..30e12acd6 100644 --- a/ssh-key/src/base64.rs +++ b/ssh-key/src/base64.rs @@ -57,7 +57,7 @@ impl<'i> Decoder<'i> { Ok(buf[0]) } - /// Decodes a `uint32` as described in [RFC4251 § 5]: + /// Decode a `uint32` as described in [RFC4251 § 5]: /// /// > Represents a 32-bit unsigned integer. Stored as four bytes in the /// > order of decreasing significance (network byte order). @@ -113,7 +113,7 @@ impl<'i> Decoder<'i> { Ok(result) } - /// Decodes a `string` as described in [RFC4251 § 5]: + /// Decode a `string` as described in [RFC4251 § 5]: /// /// > Arbitrary length binary string. Strings are allowed to contain /// > arbitrary binary data, including null characters and 8-bit @@ -146,9 +146,97 @@ impl<'i> Decoder<'i> { } } +/// Encoder trait. +pub(crate) trait Encode: Sized { + /// Get the length of this type encoded in bytes, prior to Base64 encoding. + fn encoded_len(&self) -> Result; + + /// Attempt to encode a value of this type using the provided [`Encoder`]. + fn encode(&self, encoder: &mut Encoder<'_>) -> Result<()>; +} + +/// Stateful Base64 encoder. +pub(crate) struct Encoder<'o> { + inner: base64ct::Encoder<'o, base64ct::Base64>, +} + +impl<'o> Encoder<'o> { + /// Create a new decoder for a byte slice containing contiguous + /// (non-newline-delimited) Base64-encoded data. + pub(crate) fn new(buffer: &'o mut [u8]) -> Result { + Ok(Self { + inner: base64ct::Encoder::new(buffer)?, + }) + } + + /// Encode the given byte slice as Base64. + pub(crate) fn encode(&mut self, bytes: &[u8]) -> Result<()> { + Ok(self.inner.encode(bytes)?) + } + + /// Encode a `uint32` as described in [RFC4251 § 5]: + /// + /// > Represents a 32-bit unsigned integer. Stored as four bytes in the + /// > order of decreasing significance (network byte order). + /// > For example: the value 699921578 (0x29b7f4aa) is stored as 29 b7 f4 aa. + /// + /// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5 + pub(crate) fn encode_u32(&mut self, num: u32) -> Result<()> { + self.encode(&num.to_be_bytes()) + } + + /// Encode a `usize` as a `uint32` as described in [RFC4251 § 5]. + /// + /// Uses [`Encoder::encode_u32`] after converting from a `usize`, handling + /// potential overflow if `usize` is bigger than `u32`. + /// + /// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5 + pub(crate) fn encode_usize(&mut self, num: usize) -> Result<()> { + self.encode_u32(u32::try_from(num)?) + } + + /// Encodes `[u8]` into `byte[n]` as described in [RFC4251 § 5]: + /// + /// > A byte represents an arbitrary 8-bit value (octet). Fixed length + /// > data is sometimes represented as an array of bytes, written + /// > byte[n], where n is the number of bytes in the array. + /// + /// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5 + pub(crate) fn encode_byte_slice(&mut self, bytes: &[u8]) -> Result<()> { + self.encode_usize(bytes.len())?; + self.encode(bytes) + } + + /// Encode a `string` as described in [RFC4251 § 5]: + /// + /// > Arbitrary length binary string. Strings are allowed to contain + /// > arbitrary binary data, including null characters and 8-bit + /// > characters. They are stored as a uint32 containing its length + /// > (number of bytes that follow) and zero (= empty string) or more + /// > bytes that are the value of the string. Terminating null + /// > characters are not used. + /// > + /// > Strings are also used to store text. In that case, US-ASCII is + /// > used for internal names, and ISO-10646 UTF-8 for text that might + /// > be displayed to the user. The terminating null character SHOULD + /// > NOT normally be stored in the string. For example: the US-ASCII + /// > string "testing" is represented as 00 00 00 07 t e s t i n g. The + /// > UTF-8 mapping does not alter the encoding of US-ASCII characters. + /// + /// [RFC4251 § 5]: https://datatracker.ietf.org/doc/html/rfc4251#section-5 + pub(crate) fn encode_str(&mut self, s: &str) -> Result<()> { + self.encode_byte_slice(s.as_bytes()) + } + + /// Finish encoding, returning the encoded Base64 as a `str`. + pub(crate) fn finish(self) -> Result<&'o str> { + Ok(self.inner.finish()?) + } +} + #[cfg(test)] mod tests { - use super::Decoder; + use super::{Decoder, Encoder}; /// From `id_ecdsa_p256.pub` const EXAMPLE_BASE64: &str = @@ -168,4 +256,12 @@ mod tests { let decoded = decoder.decode_into(&mut buf).unwrap(); assert_eq!(EXAMPLE_BIN, decoded); } + + #[test] + fn encode() { + let mut buffer = [0u8; EXAMPLE_BASE64.len()]; + let mut encoder = Encoder::new(&mut buffer).unwrap(); + encoder.encode(EXAMPLE_BIN).unwrap(); + assert_eq!(EXAMPLE_BASE64, encoder.finish().unwrap()); + } } diff --git a/ssh-key/src/error.rs b/ssh-key/src/error.rs index 4a7d82454..4ae42edce 100644 --- a/ssh-key/src/error.rs +++ b/ssh-key/src/error.rs @@ -85,6 +85,14 @@ impl From for Error { } } +#[cfg(feature = "alloc")] +#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))] +impl From for Error { + fn from(_: alloc::string::FromUtf8Error) -> Error { + Error::CharacterEncoding + } +} + #[cfg(feature = "ecdsa")] #[cfg_attr(docsrs, doc(cfg(feature = "ecdsa")))] impl From for Error { diff --git a/ssh-key/src/public.rs b/ssh-key/src/public.rs index e6f72654a..714abea30 100644 --- a/ssh-key/src/public.rs +++ b/ssh-key/src/public.rs @@ -18,13 +18,16 @@ pub use self::ed25519::Ed25519PublicKey; pub use self::{dsa::DsaPublicKey, rsa::RsaPublicKey}; use crate::{ - base64::{self, Decode}, + base64::{self, Decode, Encode}, Algorithm, Error, Result, }; use core::str::FromStr; #[cfg(feature = "alloc")] -use alloc::{borrow::ToOwned, string::String}; +use alloc::{ + borrow::ToOwned, + string::{String, ToString}, +}; /// SSH public key. #[derive(Clone, Debug)] @@ -67,6 +70,33 @@ impl PublicKey { }) } + /// Encode this public key as a OpenSSH-formatted public key. + pub fn encode_openssh<'o>(&self, out: &'o mut [u8]) -> Result<&'o str> { + #[cfg(not(feature = "alloc"))] + let comment = ""; + #[cfg(feature = "alloc")] + let comment = &self.comment; + + openssh::Encapsulation::encode(out, self.algorithm().as_str(), comment, |encoder| { + self.key_data.encode(encoder) + }) + } + + /// Encode this public key as an OpenSSH-formatted public key, allocating a + /// [`String`] for the result. + #[cfg(feature = "alloc")] + pub fn to_openssh(&self) -> Result { + let encoded_len = 2 + + self.algorithm().as_str().len() + + (self.key_data.encoded_len()? * 4 / 3) + + self.comment.len(); + + let mut buf = vec![0u8; encoded_len]; + let actual_len = self.encode_openssh(&mut buf)?.len(); + buf.truncate(actual_len); + Ok(String::from_utf8(buf)?) + } + /// Get the digital signature [`Algorithm`] used by this key. pub fn algorithm(&self) -> Algorithm { self.key_data.algorithm() @@ -81,6 +111,13 @@ impl FromStr for PublicKey { } } +#[cfg(feature = "alloc")] +impl ToString for PublicKey { + fn to_string(&self) -> String { + self.to_openssh().expect("SSH public key encoding error") + } +} + /// Public key data. #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[non_exhaustive] @@ -202,3 +239,24 @@ impl Decode for KeyData { } } } + +impl Encode for KeyData { + fn encoded_len(&self) -> Result { + let alg_len = self.algorithm().encoded_len()?; + let key_len = match self { + Self::Ed25519(key) => key.encoded_len()?, + #[allow(unreachable_patterns)] + _ => return Err(Error::Algorithm), + }; + Ok(alg_len + key_len) + } + + fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> { + self.algorithm().encode(encoder)?; + match self { + Self::Ed25519(key) => key.encode(encoder), + #[allow(unreachable_patterns)] + _ => Err(Error::Algorithm), + } + } +} diff --git a/ssh-key/src/public/ed25519.rs b/ssh-key/src/public/ed25519.rs index b849a1c4e..63a2f4b84 100644 --- a/ssh-key/src/public/ed25519.rs +++ b/ssh-key/src/public/ed25519.rs @@ -3,7 +3,7 @@ //! Edwards Digital Signature Algorithm (EdDSA) over Curve25519. use crate::{ - base64::{self, Decode}, + base64::{self, Decode, Encode}, Error, Result, }; use core::fmt; @@ -37,6 +37,16 @@ impl Decode for Ed25519PublicKey { } } +impl Encode for Ed25519PublicKey { + fn encoded_len(&self) -> Result { + Ok(4 + Self::BYTE_SIZE) + } + + fn encode(&self, encoder: &mut base64::Encoder<'_>) -> Result<()> { + encoder.encode_byte_slice(self.as_ref()) + } +} + impl fmt::Display for Ed25519PublicKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:X}", self) diff --git a/ssh-key/src/public/openssh.rs b/ssh-key/src/public/openssh.rs index b18b3e8df..d0826ff99 100644 --- a/ssh-key/src/public/openssh.rs +++ b/ssh-key/src/public/openssh.rs @@ -12,7 +12,7 @@ //! ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user@example.com //! ``` -use crate::{Error, Result}; +use crate::{base64, Error, Result}; use core::str; /// OpenSSH public key encapsulation parser. @@ -31,8 +31,8 @@ pub(crate) struct Encapsulation<'a> { impl<'a> Encapsulation<'a> { /// Parse the given binary data. pub(super) fn decode(mut bytes: &'a [u8]) -> Result { - let algorithm_id = parse_segment_str(&mut bytes)?; - let base64_data = parse_segment(&mut bytes)?; + let algorithm_id = decode_segment_str(&mut bytes)?; + let base64_data = decode_segment(&mut bytes)?; let comment = str::from_utf8(bytes) .map_err(|_| Error::CharacterEncoding)? .trim_end(); @@ -48,10 +48,34 @@ impl<'a> Encapsulation<'a> { comment, }) } + + /// Encode data with OpenSSH public key encapsulation. + pub(super) fn encode<'o, F>( + out: &'o mut [u8], + algorithm_id: &str, + comment: &str, + f: F, + ) -> Result<&'o str> + where + F: FnOnce(&mut base64::Encoder<'_>) -> Result<()>, + { + let mut offset = 0; + encode_str(out, &mut offset, algorithm_id)?; + encode_str(out, &mut offset, " ")?; + + let mut encoder = base64::Encoder::new(&mut out[offset..])?; + f(&mut encoder)?; + let base64_len = encoder.finish()?.len(); + + offset += base64_len; + encode_str(out, &mut offset, " ")?; + encode_str(out, &mut offset, comment)?; + Ok(str::from_utf8(&out[..offset])?) + } } /// Parse a segment of the public key. -fn parse_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> { +fn decode_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> { let start = *bytes; let mut len = 0; @@ -81,8 +105,21 @@ fn parse_segment<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> { } /// Parse a segment of the public key as a `&str`. -fn parse_segment_str<'a>(bytes: &mut &'a [u8]) -> Result<&'a str> { - str::from_utf8(parse_segment(bytes)?).map_err(|_| Error::CharacterEncoding) +fn decode_segment_str<'a>(bytes: &mut &'a [u8]) -> Result<&'a str> { + str::from_utf8(decode_segment(bytes)?).map_err(|_| Error::CharacterEncoding) +} + +/// Encode a segment of the public key. +fn encode_str(out: &mut [u8], offset: &mut usize, s: &str) -> Result<()> { + let bytes = s.as_bytes(); + + if *offset + bytes.len() > out.len() { + return Err(Error::Length); + } + + out[*offset..][..bytes.len()].copy_from_slice(bytes); + *offset += bytes.len(); + Ok(()) } #[cfg(test)] diff --git a/ssh-key/tests/public_key.rs b/ssh-key/tests/public_key.rs index 1f03bc159..19ce5c32d 100644 --- a/ssh-key/tests/public_key.rs +++ b/ssh-key/tests/public_key.rs @@ -212,3 +212,10 @@ fn decode_rsa_4096_openssh() { assert_eq!("user@example.com", ossh_key.comment); } + +#[cfg(feature = "alloc")] +#[test] +fn encode_ed25519_openssh() { + let ossh_key = PublicKey::from_openssh(OSSH_ED25519_EXAMPLE).unwrap(); + assert_eq!(OSSH_ED25519_EXAMPLE.trim_end(), &ossh_key.to_string()) +}