diff --git a/ssh-key/Cargo.toml b/ssh-key/Cargo.toml index e1814b1..097993d 100644 --- a/ssh-key/Cargo.toml +++ b/ssh-key/Cargo.toml @@ -46,7 +46,7 @@ default = ["ecdsa", "rand_core", "std"] alloc = [ "encoding/alloc", "signature/alloc", - "zeroize/alloc" + "zeroize/alloc", ] std = [ "alloc", diff --git a/ssh-key/src/algorithm.rs b/ssh-key/src/algorithm.rs index 8f497cc..100434b 100644 --- a/ssh-key/src/algorithm.rs +++ b/ssh-key/src/algorithm.rs @@ -1,5 +1,8 @@ //! Algorithm support. +#[cfg(feature = "alloc")] +mod name; + use crate::{Error, Result}; use core::{fmt, str}; use encoding::{Label, LabelError}; @@ -10,6 +13,9 @@ use { sha2::{Digest, Sha256, Sha512}, }; +#[cfg(feature = "alloc")] +pub use name::AlgorithmName; + /// bcrypt-pbkdf const BCRYPT: &str = "bcrypt"; @@ -80,7 +86,7 @@ const SK_SSH_ED25519: &str = "sk-ssh-ed25519@openssh.com"; /// /// This type provides a registry of supported digital signature algorithms /// used for SSH keys. -#[derive(Copy, Clone, Debug, Default, Eq, Hash, PartialEq, PartialOrd, Ord)] +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, PartialOrd, Ord)] #[non_exhaustive] pub enum Algorithm { /// Digital Signature Algorithm @@ -113,6 +119,10 @@ pub enum Algorithm { /// FIDO/U2F key with Ed25519 SkEd25519, + + /// Other + #[cfg(feature = "alloc")] + Other(AlgorithmName), } impl Algorithm { @@ -127,6 +137,8 @@ impl Algorithm { /// - `ssh-rsa` /// - `sk-ecdsa-sha2-nistp256@openssh.com` (FIDO/U2F key) /// - `sk-ssh-ed25519@openssh.com` (FIDO/U2F key) + /// + /// Any other algorithms are mapped to the [`Algorithm::Other`] variant. pub fn new(id: &str) -> Result { Ok(id.parse()?) } @@ -147,6 +159,8 @@ impl Algorithm { /// - `sk-ecdsa-sha2-nistp256-cert-v01@openssh.com` (FIDO/U2F key) /// - `sk-ssh-ed25519-cert-v01@openssh.com` (FIDO/U2F key) /// + /// Any other algorithms are mapped to the [`Algorithm::Other`] variant. + /// /// [PROTOCOL.certkeys]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD pub fn new_certificate(id: &str) -> Result { match id { @@ -164,12 +178,15 @@ impl Algorithm { CERT_RSA => Ok(Algorithm::Rsa { hash: None }), CERT_SK_ECDSA_SHA2_P256 => Ok(Algorithm::SkEcdsaSha2NistP256), CERT_SK_SSH_ED25519 => Ok(Algorithm::SkEd25519), + #[cfg(feature = "alloc")] + _ => Ok(Algorithm::Other(AlgorithmName::from_certificate_str(id)?)), + #[cfg(not(feature = "alloc"))] _ => Err(Error::AlgorithmUnknown), } } /// Get the string identifier which corresponds to this algorithm. - pub fn as_str(self) -> &'static str { + pub fn as_str(&self) -> &str { match self { Algorithm::Dsa => SSH_DSA, Algorithm::Ecdsa { curve } => match curve { @@ -185,6 +202,8 @@ impl Algorithm { }, Algorithm::SkEcdsaSha2NistP256 => SK_ECDSA_SHA2_P256, Algorithm::SkEd25519 => SK_SSH_ED25519, + #[cfg(feature = "alloc")] + Algorithm::Other(algorithm) => algorithm.as_str(), } } @@ -195,7 +214,7 @@ impl Algorithm { /// See [PROTOCOL.certkeys] for more information. /// /// [PROTOCOL.certkeys]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD - pub fn as_certificate_str(self) -> &'static str { + pub fn as_certificate_str(&self) -> &str { match self { Algorithm::Dsa => CERT_DSA, Algorithm::Ecdsa { curve } => match curve { @@ -207,6 +226,8 @@ impl Algorithm { Algorithm::Rsa { .. } => CERT_RSA, Algorithm::SkEcdsaSha2NistP256 => CERT_SK_ECDSA_SHA2_P256, Algorithm::SkEd25519 => CERT_SK_SSH_ED25519, + #[cfg(feature = "alloc")] + Algorithm::Other(algorithm) => algorithm.certificate_str(), } } @@ -276,6 +297,9 @@ impl str::FromStr for Algorithm { SSH_RSA => Ok(Algorithm::Rsa { hash: None }), SK_ECDSA_SHA2_P256 => Ok(Algorithm::SkEcdsaSha2NistP256), SK_SSH_ED25519 => Ok(Algorithm::SkEd25519), + #[cfg(feature = "alloc")] + _ => Ok(Algorithm::Other(AlgorithmName::from_str(id)?)), + #[cfg(not(feature = "alloc"))] _ => Err(LabelError::new(id)), } } diff --git a/ssh-key/src/algorithm/name.rs b/ssh-key/src/algorithm/name.rs new file mode 100644 index 0000000..d85bd3b --- /dev/null +++ b/ssh-key/src/algorithm/name.rs @@ -0,0 +1,109 @@ +use alloc::string::String; +use core::str::{self, FromStr}; +use encoding::LabelError; + +/// The suffix added to the `name` in a `name@domainname` algorithm string identifier. +const CERT_STR_SUFFIX: &str = "-cert-v01"; + +/// According to [RFC4251 § 6], algorithm names are ASCII strings that are at most 64 +/// characters long. +/// +/// [RFC4251 § 6]: https://www.rfc-editor.org/rfc/rfc4251.html#section-6 +const MAX_ALGORITHM_NAME_LEN: usize = 64; + +/// The maximum length of the certificate string identifier is [`MAX_ALGORITHM_NAME_LEN`] + +/// `"-cert-v01".len()` (the certificate identifier is obtained by inserting `"-cert-v01"` in the +/// algorithm name). +const MAX_CERT_STR_LEN: usize = MAX_ALGORITHM_NAME_LEN + CERT_STR_SUFFIX.len(); + +/// A string representing an additional algorithm name in the `name@domainname` format (see +/// [RFC4251 § 6]). +/// +/// Additional algorithm names must be non-empty printable ASCII strings no longer than 64 +/// characters. +/// +/// This also provides a `name-cert-v01@domainnname` string identifier for the corresponding +/// OpenSSH certificate format, derived from the specified `name@domainname` string. +/// +/// NOTE: RFC4251 specifies additional validation criteria for algorithm names, but we do not +/// implement all of them here. +/// +/// [RFC4251 § 6]: https://www.rfc-editor.org/rfc/rfc4251.html#section-6 +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct AlgorithmName { + /// The string identifier which corresponds to this algorithm. + id: String, + /// The string identifier which corresponds to the OpenSSH certificate format. + /// + /// This is derived from the algorithm name by inserting `"-cert-v01"` immediately after the + /// name preceding the at-symbol (`@`). + certificate_str: String, +} + +impl AlgorithmName { + /// Get the string identifier which corresponds to this algorithm name. + pub fn as_str(&self) -> &str { + &self.id + } + + /// Get the string identifier which corresponds to the OpenSSH certificate format. + pub fn certificate_str(&self) -> &str { + &self.certificate_str + } + + /// Create a new [`AlgorithmName`] from an OpenSSH certificate format string identifier. + pub fn from_certificate_str(id: &str) -> Result { + validate_algorithm_id(id, MAX_CERT_STR_LEN)?; + + // Derive the algorithm name from the certificate format string identifier: + let (name, domain) = split_algorithm_id(id)?; + let name = name + .strip_suffix(CERT_STR_SUFFIX) + .ok_or_else(|| LabelError::new(id))?; + + let algorithm_name = format!("{name}@{domain}"); + + Ok(Self { + id: algorithm_name, + certificate_str: id.into(), + }) + } +} + +impl FromStr for AlgorithmName { + type Err = LabelError; + + fn from_str(id: &str) -> Result { + validate_algorithm_id(id, MAX_ALGORITHM_NAME_LEN)?; + + // Derive the certificate format string identifier from the algorithm name: + let (name, domain) = split_algorithm_id(id)?; + let certificate_str = format!("{name}{CERT_STR_SUFFIX}@{domain}"); + + Ok(Self { + id: id.into(), + certificate_str, + }) + } +} + +/// Check if the length of `id` is at most `n`, and that `id` only consists of ASCII characters. +fn validate_algorithm_id(id: &str, n: usize) -> Result<(), LabelError> { + if id.len() > n || !id.is_ascii() { + return Err(LabelError::new(id)); + } + + Ok(()) +} + +/// Split a `name@domainname` algorithm string identifier into `(name, domainname)`. +fn split_algorithm_id(id: &str) -> Result<(&str, &str), LabelError> { + let (name, domain) = id.split_once('@').ok_or_else(|| LabelError::new(id))?; + + // TODO: validate name and domain_name according to the criteria from RFC4251 + if name.is_empty() || domain.is_empty() || domain.contains('@') { + return Err(LabelError::new(id)); + } + + Ok((name, domain)) +} diff --git a/ssh-key/src/lib.rs b/ssh-key/src/lib.rs index 424030e..278730a 100644 --- a/ssh-key/src/lib.rs +++ b/ssh-key/src/lib.rs @@ -174,6 +174,7 @@ pub use sha2; #[cfg(feature = "alloc")] pub use crate::{ + algorithm::AlgorithmName, certificate::Certificate, known_hosts::KnownHosts, mpint::Mpint, diff --git a/ssh-key/src/private.rs b/ssh-key/src/private.rs index ed32c8b..5e5ad8d 100644 --- a/ssh-key/src/private.rs +++ b/ssh-key/src/private.rs @@ -111,6 +111,8 @@ mod ecdsa; mod ed25519; mod keypair; #[cfg(feature = "alloc")] +mod opaque; +#[cfg(feature = "alloc")] mod rsa; #[cfg(feature = "alloc")] mod sk; @@ -124,6 +126,7 @@ pub use self::{ pub use crate::{ private::{ dsa::{DsaKeypair, DsaPrivateKey}, + opaque::{OpaqueKeypair, OpaqueKeypairBytes, OpaquePrivateKeyBytes}, rsa::{RsaKeypair, RsaPrivateKey}, sk::SkEd25519, }, diff --git a/ssh-key/src/private/keypair.rs b/ssh-key/src/private/keypair.rs index f8dc992..e21daa1 100644 --- a/ssh-key/src/private/keypair.rs +++ b/ssh-key/src/private/keypair.rs @@ -7,7 +7,7 @@ use subtle::{Choice, ConstantTimeEq}; #[cfg(feature = "alloc")] use { - super::{DsaKeypair, RsaKeypair, SkEd25519}, + super::{DsaKeypair, OpaqueKeypair, RsaKeypair, SkEd25519}, alloc::vec::Vec, }; @@ -55,6 +55,10 @@ pub enum KeypairData { /// [PROTOCOL.u2f]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD #[cfg(feature = "alloc")] SkEd25519(SkEd25519), + + /// Opaque keypair. + #[cfg(feature = "alloc")] + Other(OpaqueKeypair), } impl KeypairData { @@ -74,6 +78,8 @@ impl KeypairData { Self::SkEcdsaSha2NistP256(_) => Algorithm::SkEcdsaSha2NistP256, #[cfg(feature = "alloc")] Self::SkEd25519(_) => Algorithm::SkEd25519, + #[cfg(feature = "alloc")] + Self::Other(key) => key.algorithm(), }) } @@ -140,6 +146,15 @@ impl KeypairData { } } + /// Get the custom, opaque private key if this key is the correct type. + #[cfg(feature = "alloc")] + pub fn other(&self) -> Option<&OpaqueKeypair> { + match self { + Self::Other(key) => Some(key), + _ => None, + } + } + /// Is this key a DSA key? #[cfg(feature = "alloc")] pub fn is_dsa(&self) -> bool { @@ -187,6 +202,12 @@ impl KeypairData { matches!(self, Self::SkEd25519(_)) } + /// Is this a key with a custom algorithm? + #[cfg(feature = "alloc")] + pub fn is_other(&self) -> bool { + matches!(self, Self::Other(_)) + } + /// Compute a deterministic "checkint" for this private key. /// /// This is a sort of primitive pseudo-MAC used by the OpenSSH key format. @@ -206,6 +227,8 @@ impl KeypairData { Self::SkEcdsaSha2NistP256(sk) => sk.key_handle(), #[cfg(feature = "alloc")] Self::SkEd25519(sk) => sk.key_handle(), + #[cfg(feature = "alloc")] + Self::Other(key) => key.private.as_ref(), }; let mut n = 0u32; @@ -243,6 +266,8 @@ impl ConstantTimeEq for KeypairData { // The key structs contain all public data. Choice::from((a == b) as u8) } + #[cfg(feature = "alloc")] + (Self::Other(a), Self::Other(b)) => a.ct_eq(b), #[allow(unreachable_patterns)] _ => Choice::from(0), } @@ -278,6 +303,10 @@ impl Decode for KeypairData { } #[cfg(feature = "alloc")] Algorithm::SkEd25519 => SkEd25519::decode(reader).map(Self::SkEd25519), + #[cfg(feature = "alloc")] + algorithm @ Algorithm::Other(_) => { + OpaqueKeypair::decode_as(reader, algorithm).map(Self::Other) + } #[allow(unreachable_patterns)] _ => Err(Error::AlgorithmUnknown), } @@ -307,6 +336,8 @@ impl Encode for KeypairData { Self::SkEcdsaSha2NistP256(sk) => sk.encoded_len()?, #[cfg(feature = "alloc")] Self::SkEd25519(sk) => sk.encoded_len()?, + #[cfg(feature = "alloc")] + Self::Other(key) => key.encoded_len()?, }; [alg_len, key_len].checked_sum() @@ -331,6 +362,8 @@ impl Encode for KeypairData { Self::SkEcdsaSha2NistP256(sk) => sk.encode(writer)?, #[cfg(feature = "alloc")] Self::SkEd25519(sk) => sk.encode(writer)?, + #[cfg(feature = "alloc")] + Self::Other(key) => key.encode(writer)?, } Ok(()) @@ -357,6 +390,8 @@ impl TryFrom<&KeypairData> for public::KeyData { } #[cfg(feature = "alloc")] KeypairData::SkEd25519(sk) => public::KeyData::SkEd25519(sk.public().clone()), + #[cfg(feature = "alloc")] + KeypairData::Other(key) => public::KeyData::Other(key.into()), }) } } diff --git a/ssh-key/src/private/opaque.rs b/ssh-key/src/private/opaque.rs new file mode 100644 index 0000000..00cc569 --- /dev/null +++ b/ssh-key/src/private/opaque.rs @@ -0,0 +1,155 @@ +//! Opaque private keys. +//! +//! [`OpaqueKeypair`] represents a keypair meant to be used with an algorithm unknown to this +//! crate, i.e. keypairs that use a custom algorithm as specified in [RFC4251 § 6]. +//! +//! They are said to be opaque, because the meaning of their underlying byte representation is not +//! specified. +//! +//! [RFC4251 § 6]: https://www.rfc-editor.org/rfc/rfc4251.html#section-6 + +use crate::{ + public::{OpaquePublicKey, OpaquePublicKeyBytes}, + Algorithm, Error, Result, +}; +use alloc::vec::Vec; +use core::fmt; +use encoding::{CheckedSum, Decode, Encode, Reader, Writer}; +use subtle::{Choice, ConstantTimeEq}; + +/// An opaque private key. +/// +/// The encoded representation of an `OpaquePrivateKeyBytes` consists of a 4-byte length prefix, +/// followed by its byte representation. +#[derive(Clone)] +pub struct OpaquePrivateKeyBytes(Vec); + +/// An opaque keypair. +/// +/// The encoded representation of an `OpaqueKeypair` consists of the encoded representation of its +/// [`OpaquePublicKey`] followed by the encoded representation of its [`OpaquePrivateKeyBytes`]. +#[derive(Clone)] +pub struct OpaqueKeypair { + /// The opaque private key + pub private: OpaquePrivateKeyBytes, + /// The opaque public key + pub public: OpaquePublicKey, +} + +/// The underlying representation of an [`OpaqueKeypair`]. +/// +/// The encoded representation of an `OpaqueKeypairBytes` consists of the encoded representation of +/// its [`OpaquePublicKeyBytes`] followed by the encoded representation of its +/// [`OpaquePrivateKeyBytes`]. +pub struct OpaqueKeypairBytes { + /// The opaque private key + pub private: OpaquePrivateKeyBytes, + /// The opaque public key + pub public: OpaquePublicKeyBytes, +} + +impl OpaqueKeypair { + /// Create a new `OpaqueKeypair`. + pub fn new(private_key: Vec, public: OpaquePublicKey) -> Self { + Self { + private: OpaquePrivateKeyBytes(private_key), + public, + } + } + + /// Get the [`Algorithm`] for this key type. + pub fn algorithm(&self) -> Algorithm { + self.public.algorithm() + } + + /// Decode [`OpaqueKeypair`] for the specified algorithm. + pub(super) fn decode_as(reader: &mut impl Reader, algorithm: Algorithm) -> Result { + let key = OpaqueKeypairBytes::decode(reader)?; + let public = OpaquePublicKey { + algorithm, + key: key.public, + }; + + Ok(Self { + public, + private: key.private, + }) + } +} + +impl Decode for OpaquePrivateKeyBytes { + type Error = Error; + + fn decode(reader: &mut impl Reader) -> Result { + let len = usize::decode(reader)?; + let mut bytes = vec![0; len]; + reader.read(&mut bytes)?; + Ok(Self(bytes)) + } +} + +impl Decode for OpaqueKeypairBytes { + type Error = Error; + + fn decode(reader: &mut impl Reader) -> Result { + let public = OpaquePublicKeyBytes::decode(reader)?; + let private = OpaquePrivateKeyBytes::decode(reader)?; + + Ok(Self { public, private }) + } +} + +impl Encode for OpaqueKeypair { + fn encoded_len(&self) -> encoding::Result { + [self.public.encoded_len()?, self.private.encoded_len()?].checked_sum() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.public.encode(writer)?; + self.private.encode(writer)?; + + Ok(()) + } +} + +impl ConstantTimeEq for OpaqueKeypair { + fn ct_eq(&self, other: &Self) -> Choice { + Choice::from((self.public == other.public) as u8) & self.private.ct_eq(&other.private) + } +} + +impl Encode for OpaquePrivateKeyBytes { + fn encoded_len(&self) -> encoding::Result { + self.0.encoded_len() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.0.encode(writer) + } +} + +impl From<&OpaqueKeypair> for OpaquePublicKey { + fn from(keypair: &OpaqueKeypair) -> OpaquePublicKey { + keypair.public.clone() + } +} + +impl fmt::Debug for OpaqueKeypair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OpaqueKeypair") + .field("public", &self.public) + .finish_non_exhaustive() + } +} + +impl ConstantTimeEq for OpaquePrivateKeyBytes { + fn ct_eq(&self, other: &Self) -> Choice { + self.as_ref().ct_eq(other.as_ref()) + } +} + +impl AsRef<[u8]> for OpaquePrivateKeyBytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/ssh-key/src/public.rs b/ssh-key/src/public.rs index 64725ea..50d9582 100644 --- a/ssh-key/src/public.rs +++ b/ssh-key/src/public.rs @@ -9,6 +9,8 @@ mod ecdsa; mod ed25519; mod key_data; #[cfg(feature = "alloc")] +mod opaque; +#[cfg(feature = "alloc")] mod rsa; mod sk; mod ssh_format; @@ -16,7 +18,11 @@ mod ssh_format; pub use self::{ed25519::Ed25519PublicKey, key_data::KeyData, sk::SkEd25519}; #[cfg(feature = "alloc")] -pub use self::{dsa::DsaPublicKey, rsa::RsaPublicKey}; +pub use self::{ + dsa::DsaPublicKey, + opaque::{OpaquePublicKey, OpaquePublicKeyBytes}, + rsa::RsaPublicKey, +}; #[cfg(feature = "ecdsa")] pub use self::{ecdsa::EcdsaPublicKey, sk::SkEcdsaSha2NistP256}; diff --git a/ssh-key/src/public/key_data.rs b/ssh-key/src/public/key_data.rs index 1fd7a77..83b1510 100644 --- a/ssh-key/src/public/key_data.rs +++ b/ssh-key/src/public/key_data.rs @@ -5,7 +5,7 @@ use crate::{Algorithm, Error, Fingerprint, HashAlg, Result}; use encoding::{CheckedSum, Decode, Encode, Reader, Writer}; #[cfg(feature = "alloc")] -use super::{DsaPublicKey, RsaPublicKey}; +use super::{DsaPublicKey, OpaquePublicKey, RsaPublicKey}; #[cfg(feature = "ecdsa")] use super::{EcdsaPublicKey, SkEcdsaSha2NistP256}; @@ -39,6 +39,10 @@ pub enum KeyData { /// /// [PROTOCOL.u2f]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD SkEd25519(SkEd25519), + + /// Opaque public key data. + #[cfg(feature = "alloc")] + Other(OpaquePublicKey), } impl KeyData { @@ -55,6 +59,8 @@ impl KeyData { #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(_) => Algorithm::SkEcdsaSha2NistP256, Self::SkEd25519(_) => Algorithm::SkEd25519, + #[cfg(feature = "alloc")] + Self::Other(key) => key.algorithm(), } } @@ -118,6 +124,15 @@ impl KeyData { } } + /// Get the custom, opaque public key if this key is the correct type. + #[cfg(feature = "alloc")] + pub fn other(&self) -> Option<&OpaquePublicKey> { + match self { + Self::Other(key) => Some(key), + _ => None, + } + } + /// Is this key a DSA key? #[cfg(feature = "alloc")] pub fn is_dsa(&self) -> bool { @@ -152,6 +167,12 @@ impl KeyData { matches!(self, Self::SkEd25519(_)) } + /// Is this a key with a custom algorithm? + #[cfg(feature = "alloc")] + pub fn is_other(&self) -> bool { + matches!(self, Self::Other(_)) + } + /// Decode [`KeyData`] for the specified algorithm. pub(crate) fn decode_as(reader: &mut impl Reader, algorithm: Algorithm) -> Result { match algorithm { @@ -170,6 +191,8 @@ impl KeyData { SkEcdsaSha2NistP256::decode(reader).map(Self::SkEcdsaSha2NistP256) } Algorithm::SkEd25519 => SkEd25519::decode(reader).map(Self::SkEd25519), + #[cfg(feature = "alloc")] + Algorithm::Other(_) => OpaquePublicKey::decode_as(reader, algorithm).map(Self::Other), #[allow(unreachable_patterns)] _ => Err(Error::AlgorithmUnknown), } @@ -189,6 +212,8 @@ impl KeyData { #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(sk) => sk.encoded_len(), Self::SkEd25519(sk) => sk.encoded_len(), + #[cfg(feature = "alloc")] + Self::Other(other) => other.key.encoded_len(), } } @@ -205,6 +230,8 @@ impl KeyData { #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(sk) => sk.encode(writer), Self::SkEd25519(sk) => sk.encode(writer), + #[cfg(feature = "alloc")] + Self::Other(other) => other.key.encode(writer), } } } diff --git a/ssh-key/src/public/opaque.rs b/ssh-key/src/public/opaque.rs new file mode 100644 index 0000000..b0c6411 --- /dev/null +++ b/ssh-key/src/public/opaque.rs @@ -0,0 +1,98 @@ +//! Opaque public keys. +//! +//! [`OpaquePublicKey`] represents a public key meant to be used with an algorithm unknown to this +//! crate, i.e. public keys that use a custom algorithm as specified in [RFC4251 § 6]. +//! +//! They are said to be opaque, because the meaning of their underlying byte representation is not +//! specified. +//! +//! [RFC4251 § 6]: https://www.rfc-editor.org/rfc/rfc4251.html#section-6 + +use crate::{Algorithm, Error, Result}; +use alloc::vec::Vec; +use encoding::{Decode, Encode, Reader, Writer}; + +/// An opaque public key with a custom algorithm name. +/// +/// The encoded representation of an `OpaquePublicKey` is the encoded representation of its +/// [`OpaquePublicKeyBytes`]. +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub struct OpaquePublicKey { + /// The [`Algorithm`] of this public key. + pub algorithm: Algorithm, + /// The key data + pub key: OpaquePublicKeyBytes, +} + +/// The underlying representation of an [`OpaquePublicKey`]. +/// +/// The encoded representation of an `OpaquePublicKeyBytes` consists of a 4-byte length prefix, +/// followed by its byte representation. +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub struct OpaquePublicKeyBytes(Vec); + +impl OpaquePublicKey { + /// Create a new `OpaquePublicKey`. + pub fn new(key: Vec, algorithm: Algorithm) -> Self { + Self { + key: OpaquePublicKeyBytes(key), + algorithm, + } + } + + /// Get the [`Algorithm`] for this public key type. + pub fn algorithm(&self) -> Algorithm { + self.algorithm.clone() + } + + /// Decode [`OpaquePublicKey`] for the specified algorithm. + pub(super) fn decode_as(reader: &mut impl Reader, algorithm: Algorithm) -> Result { + Ok(Self { + algorithm, + key: OpaquePublicKeyBytes::decode(reader)?, + }) + } +} + +impl Decode for OpaquePublicKeyBytes { + type Error = Error; + + fn decode(reader: &mut impl Reader) -> Result { + let len = usize::decode(reader)?; + let mut bytes = vec![0; len]; + reader.read(&mut bytes)?; + Ok(Self(bytes)) + } +} + +impl Encode for OpaquePublicKeyBytes { + fn encoded_len(&self) -> encoding::Result { + self.0.encoded_len() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.0.encode(writer) + } +} + +impl Encode for OpaquePublicKey { + fn encoded_len(&self) -> encoding::Result { + self.key.encoded_len() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.key.encode(writer) + } +} + +impl AsRef<[u8]> for OpaquePublicKey { + fn as_ref(&self) -> &[u8] { + self.key.as_ref() + } +} + +impl AsRef<[u8]> for OpaquePublicKeyBytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/ssh-key/src/signature.rs b/ssh-key/src/signature.rs index a86206e..4723242 100644 --- a/ssh-key/src/signature.rs +++ b/ssh-key/src/signature.rs @@ -131,7 +131,7 @@ impl Signature { /// Get the [`Algorithm`] associated with this signature. pub fn algorithm(&self) -> Algorithm { - self.algorithm + self.algorithm.clone() } /// Get the raw signature as bytes. @@ -531,7 +531,7 @@ impl TryFrom<&Signature> for p256::ecdsa::Signature { _ => Err(Error::Crypto), } } - _ => Err(signature.algorithm.unsupported_error()), + _ => Err(signature.algorithm.clone().unsupported_error()), } } } @@ -561,7 +561,7 @@ impl TryFrom<&Signature> for p384::ecdsa::Signature { _ => Err(Error::Crypto), } } - _ => Err(signature.algorithm.unsupported_error()), + _ => Err(signature.algorithm.clone().unsupported_error()), } } } diff --git a/ssh-key/tests/algorithm_name.rs b/ssh-key/tests/algorithm_name.rs new file mode 100644 index 0000000..3ff5ccd --- /dev/null +++ b/ssh-key/tests/algorithm_name.rs @@ -0,0 +1,56 @@ +//! Tests for `AlgorithmName` parsing. + +#![cfg(feature = "alloc")] + +use ssh_key::AlgorithmName; +use std::str::FromStr; + +#[test] +fn additional_algorithm_name() { + const NAME: &str = "name@example.com"; + const CERT_STR: &str = "name-cert-v01@example.com"; + + let name = AlgorithmName::from_str(NAME).unwrap(); + assert_eq!(name.as_str(), NAME); + assert_eq!(name.certificate_str(), CERT_STR); + + let name = AlgorithmName::from_certificate_str(CERT_STR).unwrap(); + assert_eq!(name.as_str(), NAME); + assert_eq!(name.certificate_str(), CERT_STR); +} + +#[test] +fn invalid_algorithm_name() { + const INVALID_NAMES: &[&str] = &[ + "nameß@example.com", + "name@example@com", + "name", + "@name", + "name@", + "", + "@", + "a-name-that-is-too-long-but-would-otherwise-be-valid-@example.com", + ]; + + const INVALID_CERT_STRS: &[&str] = &[ + "nameß-cert-v01@example.com", + "name-cert-v01@example@com", + "name@example.com", + ]; + + for name in INVALID_NAMES { + assert!( + AlgorithmName::from_str(&name).is_err(), + "{:?} should be an invalid algorithm name", + name + ); + } + + for name in INVALID_CERT_STRS { + assert!( + AlgorithmName::from_certificate_str(&name).is_err(), + "{:?} should be an invalid certificate str", + name + ); + } +} diff --git a/ssh-key/tests/certificate_builder.rs b/ssh-key/tests/certificate_builder.rs index af726fb..7527c8e 100644 --- a/ssh-key/tests/certificate_builder.rs +++ b/ssh-key/tests/certificate_builder.rs @@ -105,8 +105,8 @@ fn ecdsa_nistp256_sign_and_verify() { let algorithm = Algorithm::Ecdsa { curve: EcdsaCurve::NistP256, }; - let ca_key = PrivateKey::random(&mut rng, algorithm).unwrap(); - let subject_key = PrivateKey::random(&mut rng, algorithm).unwrap(); + let ca_key = PrivateKey::random(&mut rng, algorithm.clone()).unwrap(); + let subject_key = PrivateKey::random(&mut rng, algorithm.clone()).unwrap(); let mut cert_builder = certificate::Builder::new_with_random_nonce( &mut rng, subject_key.public_key(), diff --git a/ssh-key/tests/examples/id_opaque b/ssh-key/tests/examples/id_opaque new file mode 100644 index 0000000..3c5abfd --- /dev/null +++ b/ssh-key/tests/examples/id_opaque @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAOAAAABBuYW1lQG +V4YW1wbGUuY29tAAAAIIiPJO4Xrf7QCR5n5IX7mETP7WByysHQY5DkAF9QFbRPAAAAgH7Q +MVF+0DFRAAAAEG5hbWVAZXhhbXBsZS5jb20AAAAgiI8k7het/tAJHmfkhfuYRM/tYHLKwd +BjkOQAX1AVtE8AAAAgmGyVO0te+zKF/yB8HKXuOaWQR7xIj7w7HvA278dXXHUAAAATY29t +bWVudEBleGFtcGxlLmNvbQECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-key/tests/examples/id_opaque.pub b/ssh-key/tests/examples/id_opaque.pub new file mode 100644 index 0000000..0a0c148 --- /dev/null +++ b/ssh-key/tests/examples/id_opaque.pub @@ -0,0 +1 @@ +name@example.com AAAAEG5hbWVAZXhhbXBsZS5jb20AAAAgiI8k7het/tAJHmfkhfuYRM/tYHLKwdBjkOQAX1AVtE8= comment@example.com diff --git a/ssh-key/tests/private_key.rs b/ssh-key/tests/private_key.rs index 17a59ec..d1d78b7 100644 --- a/ssh-key/tests/private_key.rs +++ b/ssh-key/tests/private_key.rs @@ -42,6 +42,10 @@ const OPENSSH_RSA_3072_EXAMPLE: &str = include_str!("examples/id_rsa_3072"); #[cfg(feature = "alloc")] const OPENSSH_RSA_4096_EXAMPLE: &str = include_str!("examples/id_rsa_4096"); +/// OpenSSH-formatted private key with a custom algorithm name +#[cfg(feature = "alloc")] +const OPENSSH_OPAQUE_EXAMPLE: &str = include_str!("examples/id_opaque"); + #[cfg(feature = "alloc")] #[test] fn decode_dsa_openssh() { @@ -367,6 +371,30 @@ fn decode_rsa_4096_openssh() { assert_eq!("user@example.com", key.comment()); } +#[cfg(all(feature = "alloc"))] +#[test] +fn decode_custom_algorithm_openssh() { + let key = PrivateKey::from_openssh(OPENSSH_OPAQUE_EXAMPLE).unwrap(); + assert!( + matches!(key.algorithm(), Algorithm::Other(name) if name.as_str() == "name@example.com") + ); + assert_eq!(Cipher::None, key.cipher()); + assert_eq!(KdfAlg::None, key.kdf().algorithm()); + assert!(key.kdf().is_none()); + + let opaque_keypair = key.key_data().other().unwrap(); + assert_eq!( + &hex!("888f24ee17adfed0091e67e485fb9844cfed6072cac1d06390e4005f5015b44f"), + opaque_keypair.public.as_ref(), + ); + assert_eq!( + &hex!("986c953b4b5efb3285ff207c1ca5ee39a59047bc488fbc3b1ef036efc7575c75"), + opaque_keypair.private.as_ref(), + ); + + assert_eq!(key.comment(), "comment@example.com"); +} + #[cfg(all(feature = "alloc"))] #[test] fn encode_dsa_openssh() { @@ -409,6 +437,12 @@ fn encode_rsa_4096_openssh() { encoding_test(OPENSSH_RSA_4096_EXAMPLE) } +#[cfg(all(feature = "alloc"))] +#[test] +fn encode_custom_algorithm_openssh() { + encoding_test(OPENSSH_OPAQUE_EXAMPLE) +} + /// Common behavior of all encoding tests #[cfg(all(feature = "alloc"))] fn encoding_test(private_key: &str) { @@ -420,7 +454,9 @@ fn encoding_test(private_key: &str) { assert_eq!(key, key2); #[cfg(feature = "std")] - encoding_integration_test(key) + if !matches!(key.algorithm(), Algorithm::Other(_)) { + encoding_integration_test(key) + } } /// Parse PEM encoded using `PrivateKey::to_openssh` using the `ssh-keygen` utility. diff --git a/ssh-key/tests/public_key.rs b/ssh-key/tests/public_key.rs index 0f78f82..05e235f 100644 --- a/ssh-key/tests/public_key.rs +++ b/ssh-key/tests/public_key.rs @@ -40,6 +40,10 @@ const OPENSSH_SK_ECDSA_P256_EXAMPLE: &str = include_str!("examples/id_sk_ecdsa_p /// Security Key (FIDO/U2F) Ed25519 OpenSSH-formatted public key const OPENSSH_SK_ED25519_EXAMPLE: &str = include_str!("examples/id_sk_ed25519.pub"); +/// OpenSSH-formatted public key with a custom algorithm name +#[cfg(feature = "alloc")] +const OPENSSH_OPAQUE_EXAMPLE: &str = include_str!("examples/id_opaque.pub"); + #[cfg(feature = "alloc")] #[test] fn decode_dsa_openssh() { @@ -304,6 +308,28 @@ fn decode_sk_ed25519_openssh() { ); } +#[cfg(all(feature = "alloc"))] +#[test] +fn decode_custom_algorithm_openssh() { + let key = PublicKey::from_openssh(OPENSSH_OPAQUE_EXAMPLE).unwrap(); + assert!( + matches!(key.algorithm(), Algorithm::Other(name) if name.as_str() == "name@example.com") + ); + + let opaque_key = key.key_data().other().unwrap(); + assert_eq!( + &hex!("888f24ee17adfed0091e67e485fb9844cfed6072cac1d06390e4005f5015b44f"), + opaque_key.as_ref(), + ); + + assert_eq!("comment@example.com", key.comment()); + + assert_eq!( + "SHA256:8GV7v5qOHG9invseKCx0NVwFocNL0MwdyRC9bfjTFGs", + &key.fingerprint(Default::default()).to_string(), + ); +} + #[cfg(feature = "alloc")] #[test] fn encode_dsa_openssh() {