From 86b8ca06bc39b9256c1c58fe3bce821a909ad524 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Thu, 10 Mar 2022 19:14:05 -0700 Subject: [PATCH] ssh-key: private key encoder Support for encoding OpenSSH private keys in PEM format --- Cargo.lock | 2 + pem-rfc7468/src/encoder.rs | 56 ++++--- pem-rfc7468/src/error.rs | 9 ++ pem-rfc7468/src/lib.rs | 13 +- ssh-key/Cargo.toml | 4 +- ssh-key/src/algorithm.rs | 100 ++++++------ ssh-key/src/base64.rs | 58 +++++++ ssh-key/src/error.rs | 9 +- ssh-key/src/lib.rs | 1 + ssh-key/src/mpint.rs | 11 ++ ssh-key/src/private.rs | 283 +++++++++++++++++++++++++++++++-- ssh-key/src/private/dsa.rs | 72 ++++++++- ssh-key/src/private/ecdsa.rs | 112 ++++++++++++- ssh-key/src/private/ed25519.rs | 64 +++++++- ssh-key/src/private/rsa.rs | 77 ++++++++- ssh-key/src/public.rs | 32 +++- ssh-key/src/public/dsa.rs | 9 ++ ssh-key/src/public/rsa.rs | 9 ++ ssh-key/tests/private_key.rs | 182 ++++++++++++++++++++- 19 files changed, 992 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d3d6abbf..790ee18ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,8 @@ dependencies = [ "hex-literal", "pem-rfc7468", "sec1", + "subtle", + "tempfile", "zeroize", ] diff --git a/pem-rfc7468/src/encoder.rs b/pem-rfc7468/src/encoder.rs index a49a4aa95..62ed4ef88 100644 --- a/pem-rfc7468/src/encoder.rs +++ b/pem-rfc7468/src/encoder.rs @@ -13,6 +13,31 @@ use alloc::string::String; #[cfg(feature = "std")] use std::io; +/// Compute the length of a PEM encoded document which encapsulates a +/// Base64-encoded body of the given length. +/// +/// The `base64_len` value does *NOT* include the trailing newline's length. +pub fn encapsulated_len(label: &str, line_ending: LineEnding, base64_len: usize) -> usize { + // TODO(tarcieri): use checked arithmetic + PRE_ENCAPSULATION_BOUNDARY.len() + + label.as_bytes().len() + + ENCAPSULATION_BOUNDARY_DELIMITER.len() + + line_ending.len() + + base64_len + + line_ending.len() + + POST_ENCAPSULATION_BOUNDARY.len() + + label.as_bytes().len() + + ENCAPSULATION_BOUNDARY_DELIMITER.len() + + line_ending.len() +} + +/// Get the length of a PEM encoded document with the given bytes and label. +pub fn encoded_len(label: &str, line_ending: LineEnding, input: &[u8]) -> usize { + let mut base64_len = Base64::encoded_len(input); + base64_len += (base64_len.saturating_sub(1) / BASE64_WRAP_WIDTH) * line_ending.len(); + encapsulated_len(label, line_ending, base64_len) +} + /// Encode a PEM document according to RFC 7468's "Strict" grammar. pub fn encode<'o>( type_label: &str, @@ -26,18 +51,6 @@ pub fn encode<'o>( Ok(str::from_utf8(&buf[..encoded_len])?) } -/// Get the length of a PEM encoded document with the given bytes and label. -pub fn encoded_len(label: &str, line_ending: LineEnding, input: &[u8]) -> usize { - // TODO(tarcieri): use checked arithmetic - let base64_len = input - .chunks((BASE64_WRAP_WIDTH * 3) / 4) - .fold(0, |acc, chunk| { - acc + Base64::encoded_len(chunk) + line_ending.len() - }); - - encoded_len_inner(label, line_ending, base64_len) -} - /// Encode a PEM document according to RFC 7468's "Strict" grammar, returning /// the result as a [`String`]. #[cfg(feature = "alloc")] @@ -153,10 +166,10 @@ impl<'l, 'o> Encoder<'l, 'o> { part.copy_from_slice(boundary_part); } - Ok(encoded_len_inner( + Ok(encapsulated_len( self.type_label, self.line_ending, - base64.len() + self.line_ending.len(), + base64.len(), )) } } @@ -174,18 +187,3 @@ impl<'l, 'o> io::Write for Encoder<'l, 'o> { Ok(()) } } - -/// Compute the length of a PEM encoded document with a Base64-encoded body of -/// the given length. -fn encoded_len_inner(label: &str, line_ending: LineEnding, base64_len: usize) -> usize { - // TODO(tarcieri): use checked arithmetic - PRE_ENCAPSULATION_BOUNDARY.len() - + label.as_bytes().len() - + ENCAPSULATION_BOUNDARY_DELIMITER.len() - + line_ending.len() - + base64_len - + POST_ENCAPSULATION_BOUNDARY.len() - + label.as_bytes().len() - + ENCAPSULATION_BOUNDARY_DELIMITER.len() - + line_ending.len() -} diff --git a/pem-rfc7468/src/error.rs b/pem-rfc7468/src/error.rs index 353dd67f9..dd0ce87d5 100644 --- a/pem-rfc7468/src/error.rs +++ b/pem-rfc7468/src/error.rs @@ -35,6 +35,12 @@ pub enum Error { /// Errors in the post-encapsulation boundary. PostEncapsulationBoundary, + + /// Unexpected PEM type label. + UnexpectedTypeLabel { + /// Type label that was expected. + expected: &'static str, + }, } impl fmt::Display for Error { @@ -53,6 +59,9 @@ impl fmt::Display for Error { Error::PostEncapsulationBoundary => { f.write_str("PEM error in post-encapsulation boundary") } + Error::UnexpectedTypeLabel { expected } => { + write!(f, "unexpected PEM type label: expecting \"{}\"", expected) + } } } } diff --git a/pem-rfc7468/src/lib.rs b/pem-rfc7468/src/lib.rs index a7ee2fa77..845a2b118 100644 --- a/pem-rfc7468/src/lib.rs +++ b/pem-rfc7468/src/lib.rs @@ -55,7 +55,7 @@ mod grammar; pub use crate::{ decoder::{decode, decode_label, Decoder}, - encoder::{encode, encoded_len, Encoder}, + encoder::{encapsulated_len, encode, encoded_len, Encoder}, error::{Error, Result}, }; pub use base64ct::LineEnding; @@ -97,4 +97,15 @@ pub type Base64Encoder<'o> = base64ct::Encoder<'o, base64ct::Base64>; pub trait PemLabel { /// Expected PEM type label for a given document, e.g. `"PRIVATE KEY"` const TYPE_LABEL: &'static str; + + /// Validate that a given label matches the expected label. + fn validate_pem_label(actual: &str) -> Result<()> { + if Self::TYPE_LABEL == actual { + Ok(()) + } else { + Err(Error::UnexpectedTypeLabel { + expected: Self::TYPE_LABEL, + }) + } + } } diff --git a/ssh-key/Cargo.toml b/ssh-key/Cargo.toml index 12ac20a2a..d229e4a94 100644 --- a/ssh-key/Cargo.toml +++ b/ssh-key/Cargo.toml @@ -22,13 +22,15 @@ zeroize = { version = "1", default-features = false } # optional dependencies sec1 = { version = "=0.3.0-pre", optional = true, default-features = false, path = "../sec1" } +subtle = { version = "2", optional = true, default-features = false } [dev-dependencies] hex-literal = "0.3" +tempfile = "3" [features] default = ["alloc", "ecdsa"] -alloc = [] +alloc = ["zeroize/alloc"] ecdsa = ["sec1"] std = ["alloc", "base64ct/std"] diff --git a/ssh-key/src/algorithm.rs b/ssh-key/src/algorithm.rs index c8738cd07..ec6c83d7e 100644 --- a/ssh-key/src/algorithm.rs +++ b/ssh-key/src/algorithm.rs @@ -1,7 +1,7 @@ //! Algorithm support. use crate::{ - base64::{Decode, DecoderExt, Encode, EncoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt, StrField}, Error, Result, }; use core::{fmt, str}; @@ -45,9 +45,6 @@ pub enum Algorithm { } impl Algorithm { - /// Maximum size of algorithms known to this crate in bytes. - const MAX_SIZE: usize = 20; - /// Decode algorithm from the given string identifier. /// /// # Supported algorithms @@ -102,21 +99,14 @@ impl Algorithm { } } -impl Decode for Algorithm { - fn decode(decoder: &mut impl DecoderExt) -> Result { - let mut buf = [0u8; Self::MAX_SIZE]; - Self::new(decoder.decode_str(&mut buf)?) +impl AsRef for Algorithm { + fn as_ref(&self) -> &str { + self.as_str() } } -impl Encode for Algorithm { - fn encoded_len(&self) -> Result { - Ok(4 + self.as_str().len()) - } - - fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { - encoder.encode_str(self.as_str()) - } +impl StrField for Algorithm { + type DecodeBuf = [u8; 20]; // max length: "ecdsa-sha2-nistpXXX" } impl fmt::Display for Algorithm { @@ -142,9 +132,6 @@ pub enum CipherAlg { } impl CipherAlg { - /// Maximum size of cipher algorithms known to this crate in bytes. - const MAX_SIZE: usize = 4; - /// Decode cipher algorithm from the given `ciphername`. /// /// # Supported ciphernames @@ -164,13 +151,16 @@ impl CipherAlg { } } -impl Decode for CipherAlg { - fn decode(decoder: &mut impl DecoderExt) -> Result { - let mut buf = [0u8; Self::MAX_SIZE]; - Self::new(decoder.decode_str(&mut buf)?) +impl AsRef for CipherAlg { + fn as_ref(&self) -> &str { + self.as_str() } } +impl StrField for CipherAlg { + type DecodeBuf = [u8; 4]; // max length: 'none' +} + impl fmt::Display for CipherAlg { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) @@ -199,9 +189,6 @@ pub enum EcdsaCurve { } impl EcdsaCurve { - /// Maximum size of a curve identifier known to this crate in bytes. - const MAX_SIZE: usize = 8; - /// Decode elliptic curve from the given string identifier. /// /// # Supported curves @@ -228,21 +215,14 @@ impl EcdsaCurve { } } -impl Decode for EcdsaCurve { - fn decode(decoder: &mut impl DecoderExt) -> Result { - let mut buf = [0u8; Self::MAX_SIZE]; - Self::new(decoder.decode_str(&mut buf)?) +impl AsRef for EcdsaCurve { + fn as_ref(&self) -> &str { + self.as_str() } } -impl Encode for EcdsaCurve { - fn encoded_len(&self) -> Result { - Ok(4 + self.as_str().len()) - } - - fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { - encoder.encode_str(self.as_str()) - } +impl StrField for EcdsaCurve { + type DecodeBuf = [u8; 8]; // max length: 'nistpXXX' } impl fmt::Display for EcdsaCurve { @@ -268,9 +248,6 @@ pub enum KdfAlg { } impl KdfAlg { - /// Maximum size of KDF algorithms known to this crate in bytes. - const MAX_SIZE: usize = 4; - /// Decode KDF algorithm from the given `kdfname`. /// /// # Supported kdfnames @@ -290,13 +267,16 @@ impl KdfAlg { } } -impl Decode for KdfAlg { - fn decode(decoder: &mut impl DecoderExt) -> Result { - let mut buf = [0u8; Self::MAX_SIZE]; - Self::new(decoder.decode_str(&mut buf)?) +impl AsRef for KdfAlg { + fn as_ref(&self) -> &str { + self.as_str() } } +impl StrField for KdfAlg { + type DecodeBuf = [u8; 4]; // max length: 'none' +} + impl fmt::Display for KdfAlg { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) @@ -313,7 +293,7 @@ impl str::FromStr for KdfAlg { /// Key Derivation Function (KDF) options. // TODO(tarcieri): stub! -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] #[non_exhaustive] pub struct KdfOptions {} @@ -331,7 +311,35 @@ impl KdfOptions { impl Decode for KdfOptions { fn decode(decoder: &mut impl DecoderExt) -> Result { + // TODO(tarcieri): stub! let mut buf = [0u8; 0]; Self::new(decoder.decode_str(&mut buf)?) } } + +impl Encode for KdfOptions { + fn encoded_len(&self) -> Result { + Ok(4) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + // TODO(tarcieri): stub! + encoder.encode_str("") + } +} + +impl fmt::Display for KdfOptions { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + // TODO(tarcieri): stub! + Ok(()) + } +} + +impl str::FromStr for KdfOptions { + type Err = Error; + + fn from_str(id: &str) -> Result { + // TODO(tarcieri): stub! + Self::new(id) + } +} diff --git a/ssh-key/src/base64.rs b/ssh-key/src/base64.rs index 4fb00a6e1..fc6dea934 100644 --- a/ssh-key/src/base64.rs +++ b/ssh-key/src/base64.rs @@ -13,6 +13,14 @@ use alloc::{string::String, vec::Vec}; /// Maximum size of a `usize` this library will accept. const MAX_SIZE: usize = 0xFFFFF; +/// Get the estimated length of data when encoded as Base64. +/// +/// This is an upper bound where the actual length might be slightly shorter. +#[cfg(feature = "alloc")] +pub(crate) fn encoded_len(input_len: usize) -> usize { + (((input_len * 4) / 3) + 3) & !3 +} + /// Decoder trait. pub(crate) trait Decode: Sized { /// Attempt to decode a value of this type using the provided [`Decoder`]. @@ -28,6 +36,34 @@ pub(crate) trait Encode: Sized { fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()>; } +/// String-like fields. +/// +/// These fields receive a blanket impl of [`Decode`] and [`Encode`]. +pub(crate) trait StrField: AsRef + str::FromStr { + /// Decoding buffer type. + /// + /// This needs to be a byte array large enough to fit the largest + /// possible value of this type. + type DecodeBuf: AsMut<[u8]> + Default; +} + +impl Decode for T { + fn decode(decoder: &mut impl DecoderExt) -> Result { + let mut buf = T::DecodeBuf::default(); + decoder.decode_str(buf.as_mut())?.parse() + } +} + +impl Encode for T { + fn encoded_len(&self) -> Result { + Ok(4 + self.as_ref().len()) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + encoder.encode_str(self.as_ref()) + } +} + /// Stateful Base64 decoder. pub(crate) type Decoder<'i> = base64ct::Decoder<'i, base64ct::Base64>; @@ -46,6 +82,12 @@ pub(crate) trait DecoderExt { /// - `Err(Error::Length)` if the exact amount of data couldn't be read fn decode_base64<'o>(&mut self, out: &'o mut [u8]) -> Result<&'o [u8]>; + /// Get the length of the remaining data after Base64 decoding. + fn decoded_len(&self) -> usize; + + /// Is decoding finished? + fn is_finished(&self) -> bool; + /// Decodes a single byte. #[cfg(feature = "ecdsa")] fn decode_u8(&mut self) -> Result { @@ -142,12 +184,28 @@ impl DecoderExt for Decoder<'_> { fn decode_base64<'o>(&mut self, out: &'o mut [u8]) -> Result<&'o [u8]> { Ok(self.decode(out)?) } + + fn decoded_len(&self) -> usize { + self.decoded_len() + } + + fn is_finished(&self) -> bool { + self.is_finished() + } } impl DecoderExt for pem::Decoder<'_> { fn decode_base64<'o>(&mut self, out: &'o mut [u8]) -> Result<&'o [u8]> { Ok(self.decode(out)?) } + + fn decoded_len(&self) -> usize { + self.decoded_len() + } + + fn is_finished(&self) -> bool { + self.is_finished() + } } /// Encoder extension trait. diff --git a/ssh-key/src/error.rs b/ssh-key/src/error.rs index 136f072fe..270f3de46 100644 --- a/ssh-key/src/error.rs +++ b/ssh-key/src/error.rs @@ -1,6 +1,7 @@ //! Error types use core::fmt; +use pem_rfc7468 as pem; /// Result type with `ssh-key`'s [`Error`] as the error type. pub type Result = core::result::Result; @@ -38,7 +39,7 @@ pub enum Error { Overflow, /// PEM encoding errors. - Pem, + Pem(pem::Error), } impl fmt::Display for Error { @@ -54,7 +55,7 @@ impl fmt::Display for Error { 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"), + Error::Pem(err) => write!(f, "{}", err), } } } @@ -93,8 +94,8 @@ impl From for Error { } impl From for Error { - fn from(_: pem_rfc7468::Error) -> Error { - Error::Pem + fn from(err: pem_rfc7468::Error) -> Error { + Error::Pem(err) } } diff --git a/ssh-key/src/lib.rs b/ssh-key/src/lib.rs index 17006496d..2783ae628 100644 --- a/ssh-key/src/lib.rs +++ b/ssh-key/src/lib.rs @@ -132,6 +132,7 @@ pub use crate::{ private::PrivateKey, public::PublicKey, }; +pub use base64ct::LineEnding; #[cfg(feature = "alloc")] pub use crate::mpint::MPInt; diff --git a/ssh-key/src/mpint.rs b/ssh-key/src/mpint.rs index 4db9fa9b6..01aa03b68 100644 --- a/ssh-key/src/mpint.rs +++ b/ssh-key/src/mpint.rs @@ -8,6 +8,9 @@ use alloc::vec::Vec; use core::fmt; use zeroize::Zeroize; +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + /// Multiple precision integer, a.k.a. "mpint". /// /// This type is used for representing the big integer components of @@ -157,6 +160,14 @@ impl fmt::UpperHex for MPInt { } } +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for MPInt { + fn ct_eq(&self, other: &Self) -> Choice { + self.as_ref().ct_eq(other.as_ref()) + } +} + #[cfg(test)] mod tests { use super::MPInt; diff --git a/ssh-key/src/private.rs b/ssh-key/src/private.rs index e4598fc11..a758cc17e 100644 --- a/ssh-key/src/private.rs +++ b/ssh-key/src/private.rs @@ -22,18 +22,37 @@ pub use self::{ }; use crate::{ - base64::{Decode, DecoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt}, public, Algorithm, CipherAlg, Error, KdfAlg, KdfOptions, PublicKey, Result, }; -use core::str::FromStr; -use pem_rfc7468::{self as pem, PemLabel}; +use core::str; +use pem_rfc7468::{self as pem, LineEnding, PemLabel}; #[cfg(feature = "alloc")] -use alloc::string::String; +use {crate::base64, alloc::string::String, zeroize::Zeroizing}; -/// Line width used by the PEM encoding of OpenSSH private keys +#[cfg(feature = "std")] +use std::{fs, io::Write, path::Path}; + +#[cfg(all(unix, feature = "std"))] +use std::os::unix::fs::OpenOptionsExt; + +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + +/// Padding bytes to use. +const PADDING_BYTES: [u8; 7] = [1, 2, 3, 4, 5, 6, 7]; + +/// Line width used by the PEM encoding of OpenSSH private keys. const PEM_LINE_WIDTH: usize = 70; +/// Block size to use for unencrypted keys. +const UNENCRYPTED_BLOCK_SIZE: usize = 8; + +/// Unix file permissions for SSH private keys. +#[cfg(all(unix, feature = "std"))] +const UNIX_FILE_PERMISSIONS: u32 = 0o600; + /// SSH private key. #[derive(Clone, Debug)] pub struct PrivateKey { @@ -59,7 +78,7 @@ impl PrivateKey { /// Magic string used to identify keys in this format. pub const AUTH_MAGIC: &'static [u8] = b"openssh-key-v1\0"; - /// Parse an OpenSSH-formatted private key. + /// Parse an OpenSSH-formatted PEM private key. /// /// OpenSSH-formatted private keys begin with the following: /// @@ -68,10 +87,7 @@ impl PrivateKey { /// ``` pub fn from_openssh(input: impl AsRef<[u8]>) -> Result { let mut pem_decoder = pem::Decoder::new_wrapped(input.as_ref(), PEM_LINE_WIDTH)?; - - if pem_decoder.type_label() != Self::TYPE_LABEL { - return Err(Error::Pem); - } + Self::validate_pem_label(pem_decoder.type_label())?; let mut auth_magic = [0u8; Self::AUTH_MAGIC.len()]; pem_decoder.decode(&mut auth_magic)?; @@ -83,7 +99,7 @@ impl PrivateKey { let cipher_alg = CipherAlg::decode(&mut pem_decoder)?; let kdf_alg = KdfAlg::decode(&mut pem_decoder)?; let kdf_options = KdfOptions::decode(&mut pem_decoder)?; - let nkeys = pem_decoder.decode_u32()? as usize; + let nkeys = pem_decoder.decode_usize()?; // TODO(tarcieri): support more than one key? if nkeys != 1 { @@ -92,14 +108,14 @@ impl PrivateKey { for _ in 0..nkeys { // TODO(tarcieri): validate decoded length - let _len = pem_decoder.decode_u32()? as usize; + let _len = pem_decoder.decode_usize()?; let _pubkey = public::KeyData::decode(&mut pem_decoder)?; } // Begin decoding unencrypted list of N private keys // See OpenSSH PROTOCOL.key § 3 // TODO(tarcieri): validate decoded length - let _len = pem_decoder.decode_u32()? as usize; + let _len = pem_decoder.decode_usize()?; let checkint1 = pem_decoder.decode_u32()?; let checkint2 = pem_decoder.decode_u32()?; @@ -113,7 +129,8 @@ impl PrivateKey { #[cfg(feature = "alloc")] let comment = pem_decoder.decode_string()?; - // TODO(tarcieri): parse/validate padding bytes? + // TODO(tarcieri): validate padding is well-formed + Ok(Self { cipher_alg, kdf_alg, @@ -124,6 +141,95 @@ impl PrivateKey { }) } + /// Encode OpenSSH-formatted (PEM) public key. + pub fn encode_openssh<'o>( + &self, + line_ending: LineEnding, + out: &'o mut [u8], + ) -> Result<&'o str> { + let mut pem_encoder = + pem::Encoder::new_wrapped(Self::TYPE_LABEL, PEM_LINE_WIDTH, line_ending, out)?; + + pem_encoder.encode(Self::AUTH_MAGIC)?; + + // TODO(tarcieri): support for encrypted private keys + self.cipher_alg.encode(&mut pem_encoder)?; + self.kdf_alg.encode(&mut pem_encoder)?; + self.kdf_options.encode(&mut pem_encoder)?; + + // TODO(tarcieri): support for encoding more than one private key + let nkeys = 1; + pem_encoder.encode_usize(nkeys)?; + + // Encode public key + let public_key_data = public::KeyData::from(&self.key_data); + pem_encoder.encode_usize(public_key_data.encoded_len()?)?; + public_key_data.encode(&mut pem_encoder)?; + + // Get private key comment + // TODO(tarcieri): comment accessor method with consistent behavior + #[cfg(not(feature = "alloc"))] + let comment = ""; + #[cfg(feature = "alloc")] + let comment = &self.comment; + + // Encode private key + let padding_len = self.padding_len()?; + debug_assert!(padding_len <= 7, "padding too long: {}", padding_len); + + pem_encoder.encode_usize(self.private_key_len()? + padding_len)?; + let checkint = public_key_data.checkint(); + pem_encoder.encode_u32(checkint)?; + pem_encoder.encode_u32(checkint)?; + self.key_data.encode(&mut pem_encoder)?; + pem_encoder.encode_str(comment)?; + pem_encoder.encode_base64(&PADDING_BYTES[..padding_len])?; + + let encoded_len = pem_encoder.finish()?; + Ok(str::from_utf8(&out[..encoded_len])?) + } + + /// Encode an OpenSSH-formatted PEM private key, allocating a + /// self-zeroizing [`String`] for the result. + #[cfg(feature = "alloc")] + #[cfg_attr(docsrs, doc(cfg(feature = "alloc")))] + pub fn to_openssh(&self, line_ending: LineEnding) -> Result> { + let encoded_len = self.openssh_encoded_len(line_ending)?; + let mut buf = vec![0u8; encoded_len]; + let actual_len = self.encode_openssh(line_ending, &mut buf)?.len(); + buf.truncate(actual_len); + Ok(Zeroizing::new(String::from_utf8(buf)?)) + } + + /// Read private key from an OpenSSH-formatted PEM file. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn read_openssh_file(path: &Path) -> Result { + // TODO(tarcieri): verify file permissions match `UNIX_FILE_PERMISSIONS` + let pem = Zeroizing::new(fs::read_to_string(path)?); + Self::from_openssh(&*pem) + } + + /// Write private key as an OpenSSH-formatted PEM file. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn write_openssh_file(&self, path: &Path, line_ending: LineEnding) -> Result<()> { + let pem = self.to_openssh(line_ending)?; + + #[cfg(not(unix))] + fs::write(path, pem.as_bytes())?; + #[cfg(unix)] + fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(UNIX_FILE_PERMISSIONS) + .open(path) + .and_then(|mut file| file.write_all(pem.as_bytes()))?; + + Ok(()) + } + /// Get the digital signature [`Algorithm`] used by this key. pub fn algorithm(&self) -> Algorithm { self.key_data.algorithm() @@ -137,6 +243,61 @@ impl PrivateKey { comment: self.comment.clone(), } } + + /// Estimated length of a PEM-encoded key in OpenSSH format. + /// + /// May be slightly longer than the actual result. + #[cfg(feature = "alloc")] + fn openssh_encoded_len(&self, line_ending: LineEnding) -> Result { + let bytes_len = Self::AUTH_MAGIC.len() + + self.cipher_alg.encoded_len()? + + self.kdf_alg.encoded_len()? + + self.kdf_options.encoded_len()? + + 4 // number of keys + + 4 + public::KeyData::from(&self.key_data).encoded_len()? + + 4 + self.private_key_len()? + + self.padding_len()?; + + let mut base64_len = base64::encoded_len(bytes_len); + base64_len += (base64_len.saturating_sub(1) / PEM_LINE_WIDTH) * line_ending.len(); + + Ok(pem::encapsulated_len( + Self::TYPE_LABEL, + line_ending, + base64_len, + )) + } + + /// Get the length of the private key data in bytes (not including padding). + fn private_key_len(&self) -> Result { + // TODO(tarcieri): comment accessor method with consistent behavior + #[cfg(not(feature = "alloc"))] + let comment_len = 0; + #[cfg(feature = "alloc")] + let comment_len = self.comment.len(); + + Ok(8 // 2 * checkints + + self.key_data.encoded_len()? + + 4 // comment length prefix + + comment_len) + } + + /// Get the number of padding bytes to add to this key (without padding). + fn padding_len(&self) -> Result { + // TODO(tarcieri): encrypted key support + let block_size = UNENCRYPTED_BLOCK_SIZE; + + match block_size.checked_sub(self.private_key_len()? % block_size) { + Some(len) => { + if len == block_size { + Ok(0) + } else { + Ok(len) + } + } + None => Err(Error::Length), + } + } } impl From for PublicKey { @@ -151,7 +312,11 @@ impl From<&PrivateKey> for PublicKey { } } -impl FromStr for PrivateKey { +impl PemLabel for PrivateKey { + const TYPE_LABEL: &'static str = "OPENSSH PRIVATE KEY"; +} + +impl str::FromStr for PrivateKey { type Err = Error; fn from_str(s: &str) -> Result { @@ -159,10 +324,32 @@ impl FromStr for PrivateKey { } } -impl PemLabel for PrivateKey { - const TYPE_LABEL: &'static str = "OPENSSH PRIVATE KEY"; +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for PrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + // TODO(tarcieri): comment accessor method with consistent behavior + #[cfg(not(feature = "alloc"))] + let comment_eq = Choice::from(1); + #[cfg(feature = "alloc")] + let comment_eq = self.comment.as_bytes().ct_eq(other.comment.as_bytes()); + + comment_eq & self.key_data.ct_eq(&other.key_data) + } } +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for PrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for PrivateKey {} + /// Private key data. #[derive(Clone, Debug)] #[non_exhaustive] @@ -285,6 +472,38 @@ impl Decode for KeypairData { } } +impl Encode for KeypairData { + fn encoded_len(&self) -> Result { + let alg_len = self.algorithm().encoded_len()?; + + let key_len = match self { + #[cfg(feature = "alloc")] + Self::Dsa(key) => key.encoded_len()?, + #[cfg(feature = "ecdsa")] + Self::Ecdsa(key) => key.encoded_len()?, + Self::Ed25519(key) => key.encoded_len()?, + #[cfg(feature = "alloc")] + Self::Rsa(key) => key.encoded_len()?, + }; + + Ok(alg_len + key_len) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.algorithm().encode(encoder)?; + + match self { + #[cfg(feature = "alloc")] + Self::Dsa(key) => key.encode(encoder), + #[cfg(feature = "ecdsa")] + Self::Ecdsa(key) => key.encode(encoder), + Self::Ed25519(key) => key.encode(encoder), + #[cfg(feature = "alloc")] + Self::Rsa(key) => key.encode(encoder), + } + } +} + impl From<&KeypairData> for public::KeyData { fn from(keypair_data: &KeypairData) -> public::KeyData { match keypair_data { @@ -298,3 +517,33 @@ impl From<&KeypairData> for public::KeyData { } } } + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for KeypairData { + fn ct_eq(&self, other: &Self) -> Choice { + // Note: constant-time with respect to key *data* comparisons, not algorithms + match (self, other) { + #[cfg(feature = "alloc")] + (Self::Dsa(a), Self::Dsa(b)) => a.ct_eq(b), + #[cfg(feature = "ecdsa")] + (Self::Ecdsa(a), Self::Ecdsa(b)) => a.ct_eq(b), + (Self::Ed25519(a), Self::Ed25519(b)) => a.ct_eq(b), + #[cfg(feature = "alloc")] + (Self::Rsa(a), Self::Rsa(b)) => a.ct_eq(b), + _ => Choice::from(0), + } + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for KeypairData { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for KeypairData {} diff --git a/ssh-key/src/private/dsa.rs b/ssh-key/src/private/dsa.rs index fd3229850..90e2f572d 100644 --- a/ssh-key/src/private/dsa.rs +++ b/ssh-key/src/private/dsa.rs @@ -1,13 +1,16 @@ //! Digital Signature Algorithm (DSA) private keys. use crate::{ - base64::{Decode, DecoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt}, public::DsaPublicKey, MPInt, Result, }; use core::fmt; use zeroize::Zeroize; +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + /// Digital Signature Algorithm (DSA) private key. /// /// Uniformly random integer `x`, such that `0 < x < q`, i.e. `x` is in the @@ -53,6 +56,42 @@ impl Drop for DsaPrivateKey { } } +impl Encode for DsaPrivateKey { + fn encoded_len(&self) -> Result { + self.inner.encoded_len() + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.inner.encode(encoder) + } +} + +impl fmt::Debug for DsaPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DsaPrivateKey").finish_non_exhaustive() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for DsaPrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + self.inner.ct_eq(&other.inner) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for DsaPrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for DsaPrivateKey {} + /// Digital Signature Algorithm (DSA) private/public keypair. #[derive(Clone)] pub struct DsaKeypair { @@ -71,6 +110,17 @@ impl Decode for DsaKeypair { } } +impl Encode for DsaKeypair { + fn encoded_len(&self) -> Result { + Ok(self.public.encoded_len()? + self.private.encoded_len()?) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.public.encode(encoder)?; + self.private.encode(encoder) + } +} + impl From for DsaPublicKey { fn from(keypair: DsaKeypair) -> DsaPublicKey { keypair.public @@ -90,3 +140,23 @@ impl fmt::Debug for DsaKeypair { .finish_non_exhaustive() } } + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for DsaKeypair { + fn ct_eq(&self, other: &Self) -> Choice { + Choice::from((self.public == other.public) as u8) & self.private.ct_eq(&other.private) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for DsaKeypair { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for DsaKeypair {} diff --git a/ssh-key/src/private/ecdsa.rs b/ssh-key/src/private/ecdsa.rs index b97d643cd..1883a7bf2 100644 --- a/ssh-key/src/private/ecdsa.rs +++ b/ssh-key/src/private/ecdsa.rs @@ -1,7 +1,7 @@ //! Elliptic Curve Digital Signature Algorithm (ECDSA) private keys. use crate::{ - base64::{Decode, DecoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt}, public::EcdsaPublicKey, Algorithm, EcdsaCurve, Error, Result, }; @@ -9,6 +9,9 @@ use core::fmt; use sec1::consts::{U32, U48, U66}; use zeroize::Zeroize; +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + /// Elliptic Curve Digital Signature Algorithm (ECDSA) private key. #[derive(Clone)] pub struct EcdsaPrivateKey { @@ -17,6 +20,11 @@ pub struct EcdsaPrivateKey { } impl EcdsaPrivateKey { + /// Borrow the inner byte array as a slice. + pub fn as_slice(&self) -> &[u8] { + self.bytes.as_ref() + } + /// Convert to the inner byte array. pub fn into_bytes(self) -> [u8; SIZE] { self.bytes @@ -38,6 +46,27 @@ impl EcdsaPrivateKey { decoder.decode_base64(&mut bytes)?; Ok(Self { bytes }) } + + /// Does this private key need to be prefixed with a leading zero? + fn needs_leading_zero(&self) -> bool { + self.bytes[0] >= 0x80 + } +} + +impl Encode for EcdsaPrivateKey { + fn encoded_len(&self) -> Result { + Ok(4usize + usize::from(self.needs_leading_zero()) + SIZE) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + encoder.encode_usize(usize::from(self.needs_leading_zero()) + SIZE)?; + + if self.needs_leading_zero() { + encoder.encode_base64(&[0])?; + } + + encoder.encode_base64(&self.bytes) + } } impl AsRef<[u8; SIZE]> for EcdsaPrivateKey { @@ -76,6 +105,26 @@ impl Drop for EcdsaPrivateKey { } } +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for EcdsaPrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + self.as_ref().ct_eq(other.as_ref()) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for EcdsaPrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for EcdsaPrivateKey {} + /// Elliptic Curve Digital Signature Algorithm (ECDSA) private/public keypair. #[derive(Clone, Debug)] pub enum EcdsaKeypair { @@ -160,6 +209,32 @@ impl Decode for EcdsaKeypair { } } +impl Encode for EcdsaKeypair { + fn encoded_len(&self) -> Result { + let public_len = EcdsaPublicKey::from(self).encoded_len()?; + + let private_len = match self { + Self::NistP256 { private, .. } => private.encoded_len()?, + Self::NistP384 { private, .. } => private.encoded_len()?, + Self::NistP521 { private, .. } => private.encoded_len()?, + }; + + Ok(public_len + private_len) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + EcdsaPublicKey::from(self).encode(encoder)?; + + match self { + Self::NistP256 { private, .. } => private.encode(encoder)?, + Self::NistP384 { private, .. } => private.encode(encoder)?, + Self::NistP521 { private, .. } => private.encode(encoder)?, + } + + Ok(()) + } +} + impl From for EcdsaPublicKey { fn from(keypair: EcdsaKeypair) -> EcdsaPublicKey { EcdsaPublicKey::from(&keypair) @@ -175,3 +250,38 @@ impl From<&EcdsaKeypair> for EcdsaPublicKey { } } } + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for EcdsaKeypair { + fn ct_eq(&self, other: &Self) -> Choice { + let public_eq = + Choice::from((EcdsaPublicKey::from(self) == EcdsaPublicKey::from(other)) as u8); + + let private_key_a = match self { + Self::NistP256 { private, .. } => private.as_slice(), + Self::NistP384 { private, .. } => private.as_slice(), + Self::NistP521 { private, .. } => private.as_slice(), + }; + + let private_key_b = match other { + Self::NistP256 { private, .. } => private.as_slice(), + Self::NistP384 { private, .. } => private.as_slice(), + Self::NistP521 { private, .. } => private.as_slice(), + }; + + public_eq & private_key_a.ct_eq(private_key_b) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for EcdsaKeypair { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for EcdsaKeypair {} diff --git a/ssh-key/src/private/ed25519.rs b/ssh-key/src/private/ed25519.rs index c28257264..ba41c949b 100644 --- a/ssh-key/src/private/ed25519.rs +++ b/ssh-key/src/private/ed25519.rs @@ -3,13 +3,16 @@ //! Edwards Digital Signature Algorithm (EdDSA) over Curve25519. use crate::{ - base64::{Decode, DecoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt}, public::Ed25519PublicKey, Error, Result, }; use core::fmt; use zeroize::{Zeroize, Zeroizing}; +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + /// Ed25519 private key. // TODO(tarcieri): use `ed25519::PrivateKey`? (doesn't exist yet) #[derive(Clone)] @@ -31,6 +34,12 @@ impl AsRef<[u8; Self::BYTE_SIZE]> for Ed25519PrivateKey { } } +impl Drop for Ed25519PrivateKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + impl fmt::Debug for Ed25519PrivateKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Ed25519PrivateKey").finish_non_exhaustive() @@ -55,12 +64,26 @@ impl fmt::UpperHex for Ed25519PrivateKey { } } -impl Drop for Ed25519PrivateKey { - fn drop(&mut self) { - self.0.zeroize(); +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for Ed25519PrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + self.as_ref().ct_eq(other.as_ref()) } } +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for Ed25519PrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for Ed25519PrivateKey {} + /// Ed25519 private/public keypair. #[derive(Clone)] pub struct Ed25519Keypair { @@ -100,15 +123,28 @@ impl Decode for Ed25519Keypair { decoder.decode_base64(&mut *bytes)?; let (priv_bytes, pub_bytes) = bytes.split_at(Ed25519PrivateKey::BYTE_SIZE); + if pub_bytes != public.as_ref() { return Err(Error::FormatEncoding); } let private = Ed25519PrivateKey(priv_bytes.try_into()?); + Ok(Self { public, private }) } } +impl Encode for Ed25519Keypair { + fn encoded_len(&self) -> Result { + Ok(self.public.encoded_len()? + 4 + Self::BYTE_SIZE) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.public.encode(encoder)?; + encoder.encode_byte_slice(&*Zeroizing::new(self.to_bytes())) + } +} + impl From for Ed25519PublicKey { fn from(keypair: Ed25519Keypair) -> Ed25519PublicKey { keypair.public @@ -128,3 +164,23 @@ impl fmt::Debug for Ed25519Keypair { .finish_non_exhaustive() } } + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for Ed25519Keypair { + fn ct_eq(&self, other: &Self) -> Choice { + Choice::from((self.public == other.public) as u8) & self.private.ct_eq(&other.private) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for Ed25519Keypair { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for Ed25519Keypair {} diff --git a/ssh-key/src/private/rsa.rs b/ssh-key/src/private/rsa.rs index 292707dd7..29750c1b6 100644 --- a/ssh-key/src/private/rsa.rs +++ b/ssh-key/src/private/rsa.rs @@ -1,13 +1,16 @@ //! Rivest–Shamir–Adleman (RSA) private keys. use crate::{ - base64::{Decode, DecoderExt}, + base64::{Decode, DecoderExt, Encode, EncoderExt}, public::RsaPublicKey, MPInt, Result, }; use core::fmt; use zeroize::Zeroize; +#[cfg(feature = "subtle")] +use subtle::{Choice, ConstantTimeEq}; + /// RSA private key. #[cfg_attr(docsrs, doc(cfg(feature = "alloc")))] #[derive(Clone)] @@ -35,6 +38,21 @@ impl Decode for RsaPrivateKey { } } +impl Encode for RsaPrivateKey { + fn encoded_len(&self) -> Result { + [&self.d, &self.iqmp, &self.p, &self.q] + .iter() + .fold(Ok(0), |acc, n| Ok(acc? + n.encoded_len()?)) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.d.encode(encoder)?; + self.iqmp.encode(encoder)?; + self.p.encode(encoder)?; + self.q.encode(encoder) + } +} + impl Drop for RsaPrivateKey { fn drop(&mut self) { self.d.zeroize(); @@ -44,6 +62,29 @@ impl Drop for RsaPrivateKey { } } +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for RsaPrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + self.d.ct_eq(&other.d) + & self.iqmp.ct_eq(&self.iqmp) + & self.p.ct_eq(&other.p) + & self.q.ct_eq(&other.q) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for RsaPrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for RsaPrivateKey {} + /// RSA private/public keypair. #[derive(Clone)] pub struct RsaKeypair { @@ -64,6 +105,20 @@ impl Decode for RsaKeypair { } } +impl Encode for RsaKeypair { + fn encoded_len(&self) -> Result { + Ok(self.public.n.encoded_len()? + + self.public.e.encoded_len()? + + self.private.encoded_len()?) + } + + fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { + self.public.n.encode(encoder)?; + self.public.e.encode(encoder)?; + self.private.encode(encoder) + } +} + impl From for RsaPublicKey { fn from(keypair: RsaKeypair) -> RsaPublicKey { keypair.public @@ -83,3 +138,23 @@ impl fmt::Debug for RsaKeypair { .finish_non_exhaustive() } } + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl ConstantTimeEq for RsaKeypair { + fn ct_eq(&self, other: &Self) -> Choice { + Choice::from((self.public == other.public) as u8) & self.private.ct_eq(&other.private) + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl PartialEq for RsaKeypair { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +#[cfg(feature = "subtle")] +#[cfg_attr(docsrs, doc(cfg(feature = "subtle")))] +impl Eq for RsaKeypair {} diff --git a/ssh-key/src/public.rs b/ssh-key/src/public.rs index 7fc01a6af..ff8da45c4 100644 --- a/ssh-key/src/public.rs +++ b/ssh-key/src/public.rs @@ -82,12 +82,12 @@ impl PublicKey { }) } - /// Encode this public key as an OpenSSH-formatted public key, allocating a - /// [`String`] for the result. + /// Encode an OpenSSH-formatted public key, allocating a [`String`] for + /// the result. #[cfg(feature = "alloc")] pub fn to_openssh(&self) -> Result { let alg_len = self.algorithm().as_str().len(); - let key_data_len = (((self.key_data.encoded_len()? * 4) / 3) + 3) & !3; + let key_data_len = base64::encoded_len(self.key_data.encoded_len()?); let comment_len = self.comment.len(); let encoded_len = 2 + alg_len + key_data_len + comment_len; @@ -219,6 +219,30 @@ impl KeyData { pub fn is_rsa(&self) -> bool { matches!(self, Self::Rsa(_)) } + + /// Compute a "checkint" from a public key. + /// + /// This is a sort of primitive pseudo-MAC used by the OpenSSH key format. + // TODO(tarcieri): true randomness or a better algorithm? + pub(crate) fn checkint(&self) -> u32 { + let bytes = match self { + #[cfg(feature = "alloc")] + Self::Dsa(dsa) => dsa.checkint_bytes(), + #[cfg(feature = "ecdsa")] + Self::Ecdsa(ecdsa) => ecdsa.as_sec1_bytes(), + Self::Ed25519(ed25519) => ed25519.as_ref(), + #[cfg(feature = "alloc")] + Self::Rsa(rsa) => rsa.checkint_bytes(), + }; + + let mut n = 0u32; + + for chunk in bytes.chunks_exact(4) { + n ^= u32::from_be_bytes(chunk.try_into().expect("not 4 bytes")); + } + + n + } } impl Decode for KeyData { @@ -243,6 +267,7 @@ 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 { #[cfg(feature = "alloc")] Self::Dsa(key) => key.encoded_len()?, @@ -258,6 +283,7 @@ impl Encode for KeyData { fn encode(&self, encoder: &mut impl EncoderExt) -> Result<()> { self.algorithm().encode(encoder)?; + match self { #[cfg(feature = "alloc")] Self::Dsa(key) => key.encode(encoder), diff --git a/ssh-key/src/public/dsa.rs b/ssh-key/src/public/dsa.rs index a5e6c6fdb..dd9c6cfe7 100644 --- a/ssh-key/src/public/dsa.rs +++ b/ssh-key/src/public/dsa.rs @@ -25,6 +25,15 @@ pub struct DsaPublicKey { pub y: MPInt, } +impl DsaPublicKey { + /// Borrow the bytes used to compute a "checkint" for this key. + /// + /// This is a sort of primitive pseudo-MAC used by the OpenSSH key format. + pub(super) fn checkint_bytes(&self) -> &[u8] { + self.y.as_bytes() + } +} + impl Decode for DsaPublicKey { fn decode(decoder: &mut impl DecoderExt) -> Result { let p = MPInt::decode(decoder)?; diff --git a/ssh-key/src/public/rsa.rs b/ssh-key/src/public/rsa.rs index d3398d7a1..f6e1b275b 100644 --- a/ssh-key/src/public/rsa.rs +++ b/ssh-key/src/public/rsa.rs @@ -18,6 +18,15 @@ pub struct RsaPublicKey { pub n: MPInt, } +impl RsaPublicKey { + /// Borrow the bytes used to compute a "checkint" for this key. + /// + /// This is a sort of primitive pseudo-MAC used by the OpenSSH key format. + pub(super) fn checkint_bytes(&self) -> &[u8] { + self.n.as_bytes() + } +} + impl Decode for RsaPublicKey { fn decode(decoder: &mut impl DecoderExt) -> Result { let e = MPInt::decode(decoder)?; diff --git a/ssh-key/tests/private_key.rs b/ssh-key/tests/private_key.rs index 661836751..30cfed794 100644 --- a/ssh-key/tests/private_key.rs +++ b/ssh-key/tests/private_key.rs @@ -6,6 +6,15 @@ use ssh_key::{Algorithm, PrivateKey}; #[cfg(feature = "ecdsa")] use ssh_key::EcdsaCurve; +#[cfg(all(feature = "alloc", feature = "subtle"))] +use ssh_key::LineEnding; + +#[cfg(all(feature = "std", feature = "subtle"))] +use { + ssh_key::PublicKey, + std::{io, process}, +}; + /// DSA OpenSSH-formatted public key #[cfg(feature = "alloc")] const OSSH_DSA_EXAMPLE: &str = include_str!("examples/id_dsa_1024"); @@ -29,9 +38,9 @@ const OSSH_ECDSA_P521_EXAMPLE: &str = include_str!("examples/id_ecdsa_p521"); #[cfg(feature = "alloc")] const OSSH_RSA_3072_EXAMPLE: &str = include_str!("examples/id_rsa_3072"); -// /// RSA (4096-bit) OpenSSH-formatted public key -// #[cfg(feature = "alloc")] -// const OSSH_RSA_4096_EXAMPLE: &str = include_str!("examples/id_rsa_4096"); +/// RSA (4096-bit) OpenSSH-formatted public key +#[cfg(feature = "alloc")] +const OSSH_RSA_4096_EXAMPLE: &str = include_str!("examples/id_rsa_4096"); #[cfg(feature = "alloc")] #[test] @@ -252,3 +261,170 @@ fn decode_rsa_3072_openssh() { ); assert_eq!("user@example.com", ossh_key.comment); } + +#[cfg(feature = "alloc")] +#[test] +fn decode_rsa_4096_openssh() { + let ossh_key = PrivateKey::from_openssh(OSSH_RSA_4096_EXAMPLE).unwrap(); + assert_eq!(Algorithm::Rsa, ossh_key.key_data.algorithm()); + + let rsa_keypair = ossh_key.key_data.rsa().unwrap(); + assert_eq!(&hex!("010001"), rsa_keypair.public.e.as_bytes()); + assert_eq!( + &hex!( + "00b45911edc6ec5e7d2261a48c46ab889b1858306271123e6f02dc914cf3c0352492e8a6b7a7925added527 + e547dcebff6d0c19c0bc9153975199f47f4964ed20f5aceed4e82556b228a0c1fbfaa85e6339ba2ff4094d9 + 4e2b09d43a3dd68225d0bbc858293cbf167b18d6374ebe79220a633d400176f1f6b46fd626acb252bf294aa + db2acd59626a023a8e5ec53ced8685164c72ca3a2ec646812c6e61ffcba740ff15c054f0691e3a8d52c79c4 + 4b7c1fc6c9704aed09ee0195bf09c5c5ba1173b7b1179be33fb3711d3b82e98f80521367a84303cb1236ebe + 8fc095683420a4de652c071d592759d42a0c9d2e73313cdfb71a071c936659433481a406308820e173b934f + be877d873fec24d31a4d3bb9a3645055ca37bf710e214e5fc250d5964c66f18e4f05a3b93f42aa0753bd044 + e45b456c0e62fdcc1fcadef72930dc8a7a96b3e27d8eecea139a00aaf2fe79063ccb78d26d537625bdf0c4c + 8a68a04ed6f965eef7a6b1da5d8e26fc57f1047b97e2c594a9e420410977f22d1751b6d9498e8e457034049 + 3c336bf86563ef03a15bc49b0ba6fe73201f64f0413ddb4d0cc5f6cf43389907e1df29e0cc388040e3371d0 + 4814140f75cac08079431043222fb91f075d76be55cbe138e3b99a605c561c49dea50e253c8306c4f4f77d9 + 96f898db64c5d8a0a15c6efa28b0934bf0b6f2b01950d877230fe4401078420fd6dd3" + ), + rsa_keypair.public.n.as_bytes(), + ); + assert_eq!( + &hex!( + "70d639ad77847429fed4f0cb037c5760127f3ae69cb03977e36675529c3f6a00941a14155c36e9bb68bcf06 + 594c142c1fe22e4ab4b088886879d6cbbcf3f499669ce861354e074c38b73c2797d0b81d8504c4f3fece179 + 52dc3778a9300905f7ef458e435eca801a4c93daceddc59452c37c930b578c543ad8ae384c5cd600dca8e8b + c9dfe948f5e2a718649b2b5fc1868b4911990d862e6ff66a02363681090855911a610a79fa7bcfe83713c2b + ae6183528d7b938b5eea86f29bfead93994fb96287cef503ea159fa0986be168fbf1402dbaa028f22082c1a + 6cf80dd66f8637cf3d18c677fd72ea97d4849387670b1b3dc87f2295e6b77aa0e36be8a37cc864f2786dfaa + 3c4522836e4433d8dd9c464c155a78ac19e49b01f56959003baacd5183c0aeef1ff0da9988feaa1db7aadfd + 5e243ea5095d3448f5411e198ff29e2bafda1c26007effe369686171615625af5634dc98ce81ed024ea559d + 8bfefc6e7172a9d1409273f94d0235b2e76bb41a532db6ce9f0a28d4eb0db70abced95462eec5e222d3b3e6 + 1c330a9c97913a7de628524e3daa24ce93d2acf9440e03c57063e3c7509332addf37d5c840375a0b9de1d68 + 822cdcf88b44cd6aea8bfc646ce52f9d05e7e867a32d462e35a163c15b7df00e9c0a870345b86e7882971b4 + d79d42507ade7c26e6db29f52fbe58430f915c554145faa950ae6b6e4f87bf24a61" + ), + rsa_keypair.private.d.as_bytes() + ); + assert_eq!( + &hex!( + "580f3580a2ad54f7e4390a99a05b377730aebe631cd41f6452424e03763d2d43af327f919aa96bf748a3041 + fd6f76b471b4b8029ebac01df18692b1612c5d046640083ab123546495bbfab77d5f9a4b8ebeffce997417e + a5625b070be1cf8c5b253edd826be6042ee1f71a13b72e8df6fdcb7f8a945ef5929a4c790803bc31feff24f + 8f148926ea3aa02c690889baeeb1e727295642f13955067fee400b230876252fd9dcf1f56a4307d3d717cf0 + 235833fdc93947a2b4ed45d43df51087d91d59eb0bf09fe6f45036b23c944addce2976b805425c6841129be + 5b17c4dcc41d62daa053d06a1fbfd3c20543a63066ad69933ae64538c305ae645d81557a6f3c9" + ), + rsa_keypair.private.iqmp.as_bytes() + ); + assert_eq!( + &hex!( + "00e2b7aa95621ec065acd1b9edd46090c715e1d212f11ac24f61c3016a4b411a25c635007654dc19a145531 + f9d49f796965a28f67575a5a9bf852b53474c4345cf604d40b614e31d50f0ca56414f152b49d6b92d8767d4 + 70a5a10afb6e546189d6e99739aeff7a081d96fd5c1646c5abbb8481df936c65aad51a553596e16f49b09f8 + 8175d2c938f92ecaccc61313523fd678533007f05cab51dfd16cfaf3439033bd3a4d845c08e34097b6f7ecd + 0613082e7d1830f936e29c7865c2b8acd30870dd20679788e0b2aaa2285d35ea7347c4083e2ee9c92dcb11e + ea114245c5f22d7afeb9d51cbc0ca17116261fac8a8f3c3054da1f53ad297f8ce184663ec4e617d" + ), + rsa_keypair.private.p.as_bytes() + ); + assert_eq!( + &hex!( + "00cba436332637becfdbdec249dbc121e5309c9964f2053c221b58b601846afd7cc8b788d6bf9b71345b1bd + de7b367204e010ee60c2126352476ce98899b72035eb5f2ae8dd9754dd500354c418cbbf75dfd4bf2029a9a + 3c8e097efdb334e8228a738b1c3fac43b4822364a54b4c348042369b59cf086b25db23226f71edeae58e77f + c6f10493641c4254c28999be5628cd74e259d5fe5d39c98a9c0b8543bd58c89bb34ea19e18af714f1446e29 + 3d09881ed7fa5f49b374bcab97dafa067e8eb63bc9ddf2668bf3ebb2bb585d7b12ff591e6ff34889196b9e5 + 293809f168d681bb7b09680fef093c8a28ef0d25568fce4ab5e879fee21a7525ac08caf9efa2d8f" + ), + rsa_keypair.private.q.as_bytes() + ); + assert_eq!("user@example.com", ossh_key.comment); +} + +#[cfg(all(feature = "alloc", feature = "subtle"))] +#[test] +fn encode_dsa_openssh() { + encoding_test(OSSH_DSA_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "ecdsa", feature = "subtle"))] +#[test] +fn encode_ecdsa_p256_openssh() { + encoding_test(OSSH_ECDSA_P256_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "ecdsa", feature = "subtle"))] +#[test] +fn encode_ecdsa_p384_openssh() { + encoding_test(OSSH_ECDSA_P384_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "ecdsa", feature = "subtle"))] +#[test] +fn encode_ecdsa_p521_openssh() { + encoding_test(OSSH_ECDSA_P521_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "subtle"))] +#[test] +fn encode_ed25519_openssh() { + encoding_test(OSSH_ED25519_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "subtle"))] +#[test] +fn encode_rsa_3072_openssh() { + encoding_test(OSSH_RSA_3072_EXAMPLE) +} + +#[cfg(all(feature = "alloc", feature = "subtle"))] +#[test] +fn encode_rsa_4096_openssh() { + encoding_test(OSSH_RSA_4096_EXAMPLE) +} + +/// Common behavior of all encoding tests +#[cfg(all(feature = "alloc", feature = "subtle"))] +fn encoding_test(private_key: &str) { + let ossh_key = PrivateKey::from_openssh(private_key).unwrap(); + + // Ensure key round-trips + let ossh_pem = ossh_key.to_openssh(LineEnding::LF).unwrap(); + let ossh_key2 = PrivateKey::from_openssh(&*ossh_pem).unwrap(); + assert_eq!(ossh_key, ossh_key2); + + #[cfg(feature = "std")] + encoding_integration_test(ossh_key) +} + +/// Parse PEM encoded using `PrivateKey::to_openssh` using the `ssh-keygen` utility. +#[cfg(all(feature = "std", feature = "subtle"))] +fn encoding_integration_test(private_key: PrivateKey) { + let dir = tempfile::tempdir().unwrap(); + let mut path = dir.path().to_owned(); + path.push("id_example"); + + private_key + .write_openssh_file(&path, LineEnding::LF) + .unwrap(); + + let public_key = match process::Command::new("ssh-keygen") + .args(["-y", "-f", path.to_str().unwrap()]) + .output() + { + Ok(output) => { + assert_eq!(output.status.code().unwrap(), 0); + PublicKey::from_openssh(&output.stdout).unwrap() + } + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + eprintln!("couldn't find 'ssh-keygen'! skipping test"); + return; + } else { + panic!("error invoking ssh-keygen: {}", err) + } + } + }; + + // Ensure ssh-keygen successfully parsed our public key + assert_eq!(public_key, private_key.public_key()); +}