aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--oxish-proto/src/host_keys.rs397
-rw-r--r--oxish-proto/src/lib.rs8
-rw-r--r--oxish-proto/src/oxish.service20
-rw-r--r--oxish-proto/tests/decode.rs6
-rw-r--r--oxish/src/bin/oxish-server.rs15
-rw-r--r--oxish/src/tests.rs43
6 files changed, 482 insertions, 7 deletions
diff --git a/oxish-proto/src/host_keys.rs b/oxish-proto/src/host_keys.rs
index b8ead0c..2df024d 100644
--- a/oxish-proto/src/host_keys.rs
+++ b/oxish-proto/src/host_keys.rs
@@ -1,9 +1,14 @@
+use core::str::{self, FromStr};
+use std::{fs, path::Path};
+
+use tracing::{debug, warn};
use zeroize::Zeroizing;
use crate::{
Decode, Decoded, Encode, ProtoError, PublicKeyAlgorithm,
crypto::{CryptoError, CryptoProvider, SigningKey},
key_exchange::Negotiated,
+ named::Named,
};
/// The server's host keys, used to authenticate the key exchange
@@ -11,6 +16,79 @@ use crate::{
pub struct HostKeys(Vec<(Zeroizing<Vec<u8>>, Box<dyn SigningKey>)>);
impl HostKeys {
+ /// Find host keys in the given directory
+ ///
+ /// Scans `dir` (usually `/etc/ssh`) for files named `ssh_host_*_key` containing
+ /// unencrypted private keys in the OpenSSH key format. Keys using algorithms not
+ /// known to this implementation are skipped.
+ pub fn from_dir(dir: &Path, provider: &dyn CryptoProvider) -> Result<Self, ProtoError> {
+ let mut keys = Vec::new();
+ let mut error = None;
+ for entry in dir.read_dir()? {
+ let Ok(entry) = entry else {
+ debug!("skipping unreadable entry in host key directory");
+ continue;
+ };
+
+ let Ok(ty) = entry.file_type() else {
+ debug!(name = ?entry.file_name(), "skipping unreadable entry in host key directory");
+ continue;
+ };
+
+ if !ty.is_file() {
+ continue;
+ }
+
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else {
+ continue;
+ };
+
+ if !name.starts_with("ssh_host_") || !name.ends_with("_key") {
+ continue;
+ }
+
+ let pem = match fs::read_to_string(entry.path()) {
+ Ok(pem) => Zeroizing::new(pem),
+ Err(e) => {
+ debug!(name, error = %e, "skipping unreadable host key file");
+ error = Some((ProtoError::Io(e), entry.path().to_owned()));
+ continue;
+ }
+ };
+
+ let decoded = match OpenSshKeyV1::from_str(&pem) {
+ Ok(keys) => keys,
+ Err(e) => {
+ debug!(name, "skipping host key file with invalid format");
+ error = Some((e, entry.path().to_owned()));
+ continue;
+ }
+ };
+
+ for pkcs8 in decoded.keys {
+ let signing_key = provider.signing_key_from_pkcs8(&pkcs8)?;
+ keys.push((pkcs8, signing_key));
+ }
+
+ if keys.len() >= Self::MAX_KEYS {
+ return Err(ProtoError::TooManyHostKeys);
+ }
+ }
+
+ if keys.is_empty() {
+ return Err(match error {
+ Some((error, path)) => {
+ warn!(?path, %error, "no valid host keys found in directory");
+ error
+ }
+ None => ProtoError::NoHostKeys,
+ });
+ }
+
+ Ok(Self(keys))
+ }
+
/// Create a new set of host keys from the given PKCS#8 private keys
///
/// `pkcs8` must have more than 0 and less than 16 elements.
@@ -52,6 +130,12 @@ impl HostKeys {
self.0.iter().map(|(_, key)| key.algorithm())
}
+ /// The number of host keys
+ #[expect(clippy::len_without_is_empty)]
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
const MAX_KEYS: usize = 16;
}
@@ -104,3 +188,316 @@ impl SessionHostKey {
self.0.algorithm()
}
}
+
+/// Extract a PKCS#8 document from an unencrypted OpenSSH-format private key file
+///
+/// Only supports unencrypted keys for now.
+struct OpenSshKeyV1 {
+ keys: Vec<Zeroizing<Vec<u8>>>,
+}
+
+// Format:
+//
+// byte[] "openssh-key-v1"
+// string ciphername
+// string kdfname
+// string kdfoptions
+// uint32 number of keys N
+// string publickey1
+// string publickey2
+// ...
+// string publickeyN
+// string encrypted, padded list of private keys
+//
+// Unencrypted private keys:
+//
+// uint32 checkint
+// uint32 checkint
+// byte[] privatekey1
+// string comment1
+// byte[] privatekey2
+// string comment2
+// ...
+// byte[] privatekeyN
+// string commentN
+// byte 1
+// byte 2
+// byte 3
+// ...
+// byte padlen % 255
+impl FromStr for OpenSshKeyV1 {
+ type Err = ProtoError;
+
+ fn from_str(input: &str) -> Result<Self, Self::Err> {
+ let mut lines = input.lines();
+ if lines.next() != Some("-----BEGIN OPENSSH PRIVATE KEY-----") {
+ return Err(ProtoError::InvalidHostKey("missing PEM header"));
+ }
+
+ let mut base64 = Zeroizing::new(String::new());
+ let mut footer = false;
+ for line in lines {
+ if line == "-----END OPENSSH PRIVATE KEY-----" {
+ footer = true;
+ break;
+ }
+
+ base64.push_str(line);
+ }
+
+ if !footer {
+ return Err(ProtoError::InvalidHostKey("missing PEM footer"));
+ }
+
+ let Ok(blob) = data_encoding::BASE64.decode(base64.as_bytes()) else {
+ return Err(ProtoError::InvalidHostKey("invalid base64"));
+ };
+
+ let blob = Zeroizing::new(blob);
+ let Some(next) = blob.strip_prefix(b"openssh-key-v1\0") else {
+ return Err(ProtoError::InvalidHostKey("invalid magic"));
+ };
+
+ let Decoded {
+ value: cipher,
+ next,
+ } = <&[u8]>::decode(next)?;
+ let Decoded { value: kdf, next } = <&[u8]>::decode(next)?;
+ let Decoded { next, .. } = <&[u8]>::decode(next)?;
+ if cipher != b"none" || kdf != b"none" {
+ return Err(ProtoError::InvalidHostKey(
+ "encrypted keys are not supported",
+ ));
+ }
+
+ let Decoded {
+ value: key_count,
+ next,
+ } = u32::decode(next)?;
+ if key_count == 0 {
+ return Err(ProtoError::InvalidHostKey("no keys found"));
+ }
+
+ let mut next = next;
+ for _ in 0..key_count {
+ next = <&[u8]>::decode(next)?.next;
+ }
+
+ let Decoded {
+ value: private,
+ next,
+ } = <&[u8]>::decode(next)?;
+ if !next.is_empty() {
+ return Err(ProtoError::InvalidHostKey(
+ "trailing data after private key section",
+ ));
+ }
+
+ let Decoded {
+ value: check1,
+ next,
+ } = u32::decode(private)?;
+ let Decoded {
+ value: check2,
+ next,
+ } = u32::decode(next)?;
+ if check1 != check2 {
+ return Err(ProtoError::InvalidHostKey("check values do not match"));
+ }
+
+ let mut keys = Vec::with_capacity(key_count as usize);
+ let mut next_key = next;
+ for _ in 0..key_count {
+ let Decoded {
+ value: key_type,
+ next,
+ } = <&[u8]>::decode(next_key)?;
+ let Ok(key_type) = str::from_utf8(key_type) else {
+ return Err(ProtoError::InvalidHostKey("invalid key type"));
+ };
+
+ let next = match PublicKeyAlgorithm::typed(key_type) {
+ PublicKeyAlgorithm::Ed25519 => {
+ let Decoded { value, next } = SshEd25519Key::decode(next)?;
+ keys.push(value.to_pkcs8());
+ next
+ }
+ PublicKeyAlgorithm::EcdsaSha2Nistp256 => {
+ let Decoded { value, next } = SshEcdsaKey::decode(next)?;
+ keys.push(value.to_pkcs8());
+ next
+ }
+ PublicKeyAlgorithm::Unknown(_) => {
+ return Err(ProtoError::InvalidHostKey("unsupported key type"));
+ }
+ };
+
+ next_key = <&[u8]>::decode(next)?.next;
+ }
+
+ for (i, &byte) in next_key.iter().enumerate() {
+ if byte != (i + 1) as u8 {
+ return Err(ProtoError::InvalidHostKey("invalid padding"));
+ }
+ }
+
+ Ok(Self { keys })
+ }
+}
+
+struct SshEcdsaKey<'a> {
+ scalar: &'a [u8],
+ public: &'a [u8],
+}
+
+impl<'a> SshEcdsaKey<'a> {
+ fn to_pkcs8(&self) -> Zeroizing<Vec<u8>> {
+ /// PKCS#8 v1 prefix for an ECDSA P-256 private key (RFC 5915), up to the 32-byte scalar
+ ///
+ /// `SEQUENCE { INTEGER 0, SEQUENCE { OID 1.2.840.10045.2.1, OID 1.2.840.10045.3.1.7 },
+ /// OCTET STRING { SEQUENCE { INTEGER 1, OCTET STRING ... } } }`
+ const ECDSA_P256_PKCS8_PREFIX: &[u8] = &[
+ 0x30, 0x81, 0x87, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce,
+ 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04,
+ 0x6d, 0x30, 0x6b, 0x02, 0x01, 0x01, 0x04, 0x20,
+ ];
+
+ /// Continuation of [`ECDSA_P256_PKCS8_PREFIX`] between the scalar and the 65-byte public point
+ ///
+ /// `[1] { BIT STRING }`
+ const ECDSA_P256_PKCS8_MIDDLE: &[u8] = &[0xa1, 0x44, 0x03, 0x42, 0x00];
+
+ let mut pkcs8 = Zeroizing::new(Vec::with_capacity(
+ ECDSA_P256_PKCS8_PREFIX.len() + 32 + ECDSA_P256_PKCS8_MIDDLE.len() + 65,
+ ));
+ pkcs8.extend_from_slice(ECDSA_P256_PKCS8_PREFIX);
+ let padded = ECDSA_P256_PKCS8_PREFIX.len() + (32 - self.scalar.len());
+ pkcs8.resize(padded, 0);
+ pkcs8.extend_from_slice(self.scalar);
+ pkcs8.extend_from_slice(ECDSA_P256_PKCS8_MIDDLE);
+ pkcs8.extend_from_slice(self.public);
+ pkcs8
+ }
+}
+
+impl<'a> Decode<'a> for SshEcdsaKey<'a> {
+ fn decode(input: &'a [u8]) -> Result<Decoded<'a, Self>, ProtoError> {
+ let Decoded { value: curve, next } = <&[u8]>::decode(input)?;
+ if curve != b"nistp256" {
+ return Err(ProtoError::InvalidHostKey(
+ "unexpected curve for ecdsa-sha2-nistp256",
+ ));
+ }
+
+ let Decoded {
+ value: public,
+ next,
+ } = <&[u8]>::decode(next)?;
+ let Decoded {
+ value: scalar,
+ next,
+ } = <&[u8]>::decode(next)?;
+
+ let mut scalar = scalar;
+ while let [0, rest @ ..] = scalar {
+ scalar = rest;
+ }
+
+ if public.len() != 65 || scalar.is_empty() || scalar.len() > 32 {
+ return Err(ProtoError::InvalidHostKey("invalid ecdsa key data"));
+ }
+
+ Ok(Decoded {
+ value: Self { scalar, public },
+ next,
+ })
+ }
+}
+
+struct SshEd25519Key<'a> {
+ #[expect(dead_code)]
+ public: &'a [u8],
+ private: &'a [u8],
+}
+
+impl SshEd25519Key<'_> {
+ fn to_pkcs8(&self) -> Zeroizing<Vec<u8>> {
+ /// PKCS#8 v1 (RFC 5208) prefix for an Ed25519 private key (RFC 8410), up to the 32-byte seed
+ ///
+ /// `SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 }, OCTET STRING { OCTET STRING } }`
+ const ED25519_PKCS8_PREFIX: &[u8] = &[
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
+ 0x04, 0x20,
+ ];
+
+ let mut pkcs8 = Zeroizing::new(Vec::with_capacity(ED25519_PKCS8_PREFIX.len() + 32));
+ pkcs8.extend_from_slice(ED25519_PKCS8_PREFIX);
+ pkcs8.extend_from_slice(&self.private[..32]);
+ pkcs8
+ }
+}
+
+impl<'a> Decode<'a> for SshEd25519Key<'a> {
+ fn decode(bytes: &'a [u8]) -> Result<Decoded<'a, Self>, ProtoError> {
+ let Decoded {
+ value: public,
+ next,
+ } = <&[u8]>::decode(bytes)?;
+
+ let Decoded {
+ value: private,
+ next,
+ } = <&[u8]>::decode(next)?;
+
+ if public.len() != 32 || private.len() != 64 || private[32..] != *public {
+ return Err(ProtoError::InvalidHostKey("invalid ed25519 key data"));
+ }
+
+ Ok(Decoded {
+ value: Self { public, private },
+ next,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn openssh_ed25519_key() {
+ let keys = OpenSshKeyV1::from_str(ED25519_KEY).unwrap();
+ let expected = data_encoding::HEXLOWER
+ .decode(b"302e020100300506032b657004220420973548d5e2993b158ba0bd0d3582c155560e68ff3a0950f650939cc87aab45ba")
+ .unwrap();
+ assert_eq!(*keys.keys[0], expected);
+ }
+
+ #[test]
+ fn openssh_ecdsa_key() {
+ let keys = OpenSshKeyV1::from_str(ECDSA_KEY).unwrap();
+ let expected = data_encoding::HEXLOWER
+ .decode(b"308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b020101042018a3b62a37e956048f449849d41825b8491a6d1d0091589bcf0146edbf517464a1440342000470c85a09c02960bc0da257d4437611c3f0bc4abb10cb6ef0e858cad06b44e40d54be0a8bf1007192ef04802672dc9f88f0a3b813a9545b9d9de797492eaf46ab")
+ .unwrap();
+ assert_eq!(*keys.keys[0], expected);
+ }
+
+ const ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACDXl3FOtNA7kAGgEi9HtmxhmtlqWxHTZmfFXnKiYhsdPwAAAJB+4DeLfuA3
+iwAAAAtzc2gtZWQyNTUxOQAAACDXl3FOtNA7kAGgEi9HtmxhmtlqWxHTZmfFXnKiYhsdPw
+AAAECXNUjV4pk7FYugvQ01gsFVVg5o/zoJUPZQk5zIeqtFuteXcU600DuQAaASL0e2bGGa
+2WpbEdNmZ8VecqJiGx0/AAAAB2ZpeHR1cmUBAgMEBQY=
+-----END OPENSSH PRIVATE KEY-----
+";
+
+ const ECDSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
+1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRwyFoJwClgvA2iV9RDdhHD8LxKuxDL
+bvDoWMrQa0TkDVS+CovxAHGS7wSAJnLcn4jwo7gTqVRbnZ3nl0kur0arAAAAmOhdh/voXY
+f7AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHDIWgnAKWC8DaJX
+1EN2EcPwvEq7EMtu8OhYytBrROQNVL4Ki/EAcZLvBIAmctyfiPCjuBOpVFudneeXSS6vRq
+sAAAAgGKO2KjfpVgSPRJhJ1BgluEkabR0AkVibzwFG7b9RdGQAAAAA
+-----END OPENSSH PRIVATE KEY-----
+";
+}
diff --git a/oxish-proto/src/lib.rs b/oxish-proto/src/lib.rs
index f2b3b5b..0ea304e 100644
--- a/oxish-proto/src/lib.rs
+++ b/oxish-proto/src/lib.rs
@@ -618,7 +618,7 @@ pub struct Decoded<'a, T> {
}
/// An error in the SSH protocol layer
-#[derive(Debug, Error, PartialEq)]
+#[derive(Debug, Error)]
pub enum ProtoError {
/// A cryptographic operation failed
#[error("crypto error: {0}")]
@@ -626,12 +626,18 @@ pub enum ProtoError {
/// The peer's identification string was malformed
#[error("failed to parse identification: {0}")]
Identification(#[from] IdentificationError),
+ /// An I/O error occurred
+ #[error("I/O error: {0}")]
+ Io(#[from] std::io::Error),
/// The input was too short to decode a complete message
///
/// The payload, if known, is the number of additional bytes needed beyond the end of the
/// current input (`required - available`).
#[error("incomplete message: {0:?}")]
Incomplete(Option<usize>),
+ /// A host key file could not be used; the payload describes why
+ #[error("invalid host key: {0}")]
+ InvalidHostKey(&'static str),
/// A packet violated the protocol; the payload describes how
#[error("invalid packet: {0}")]
InvalidPacket(&'static str),
diff --git a/oxish-proto/src/oxish.service b/oxish-proto/src/oxish.service
new file mode 100644
index 0000000..404ab2c
--- /dev/null
+++ b/oxish-proto/src/oxish.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=OxiSH SSH server
+After=network.target nss-user-lookup.target auditd.service
+ConditionPathExists=!/etc/ssh/sshd_not_to_be_run
+
+[Service]
+ExecStartPre=/usr/sbin/sshd -t
+ExecStart=/usr/sbin/sshd -D $SSHD_OPTS
+ExecReload=/usr/sbin/sshd -t
+ExecReload=/bin/kill -HUP $MAINPID
+KillMode=process
+Restart=on-failure
+RestartPreventExitStatus=255
+Type=notify
+RuntimeDirectory=sshd
+RuntimeDirectoryMode=0755
+
+[Install]
+WantedBy=multi-user.target
+Alias=sshd.service
diff --git a/oxish-proto/tests/decode.rs b/oxish-proto/tests/decode.rs
index 99cbe2e..8aecfd2 100644
--- a/oxish-proto/tests/decode.rs
+++ b/oxish-proto/tests/decode.rs
@@ -4,10 +4,10 @@ use oxish_proto::{Completion, IncomingPacket, ProtoError, ReadState};
fn padding_too_large() {
const PACKET: &[u8] = include_bytes!("padding-too-large.bin");
let mut state = ReadState::default();
- assert_eq!(
+ assert!(matches!(
decode(PACKET, &mut state).unwrap_err(),
- ProtoError::InvalidPacket("padding length exceeds packet length")
- );
+ ProtoError::InvalidPacket("padding length exceeds packet length"),
+ ));
}
fn decode<'a>(bytes: &[u8], state: &'a mut ReadState) -> Result<IncomingPacket<'a>, ProtoError> {
diff --git a/oxish/src/bin/oxish-server.rs b/oxish/src/bin/oxish-server.rs
index a88995e..b95fb12 100644
--- a/oxish/src/bin/oxish-server.rs
+++ b/oxish/src/bin/oxish-server.rs
@@ -3,7 +3,7 @@ use std::{
env,
fs::{self, File},
io::{self, Write},
- path::PathBuf,
+ path::{Path, PathBuf},
sync::Arc,
};
@@ -49,8 +49,17 @@ async fn main() -> anyhow::Result<()> {
Err(err) => return Err(err.into()),
}
} else {
- let pkcs8 = Zeroizing::new(fs::read(&args.host_key_file)?);
- HostKeys::new([pkcs8].into_iter(), provider)?
+ match HostKeys::from_dir(Path::new("/etc/ssh"), provider) {
+ Ok(host_keys) => {
+ info!(len = host_keys.len(), "loaded host keys from /etc/ssh");
+ host_keys
+ }
+ Err(error) => {
+ eprintln!("failed to load host keys from /etc/ssh: {error}");
+ let pkcs8 = Zeroizing::new(fs::read(&args.host_key_file)?);
+ HostKeys::new([pkcs8].into_iter(), provider)?
+ }
+ }
};
let session_bin = match args.session_bin {
diff --git a/oxish/src/tests.rs b/oxish/src/tests.rs
index 3470b97..4b9d65d 100644
--- a/oxish/src/tests.rs
+++ b/oxish/src/tests.rs
@@ -303,6 +303,49 @@ impl CliClient {
}
}
+#[cfg(feature = "aws-lc")]
+#[tokio::test]
+async fn host_keys_from_dir_aws_lc() {
+ host_keys_from_dir(aws_lc::DEFAULT_PROVIDER).await.unwrap();
+}
+
+#[cfg(feature = "graviola")]
+#[tokio::test]
+async fn host_keys_from_dir_graviola() {
+ host_keys_from_dir(graviola::DEFAULT_PROVIDER)
+ .await
+ .unwrap();
+}
+
+async fn host_keys_from_dir(provider: &'static dyn CryptoProvider) -> anyhow::Result<()> {
+ let dir = TempDir::new()?;
+ for key_type in ["ed25519", "ecdsa", "rsa"] {
+ let status = Command::new("ssh-keygen")
+ .arg("-q")
+ .args(["-t", key_type])
+ .args(["-N", ""])
+ .args(["-C", "oxish-e2e"])
+ .arg("-f")
+ .arg(dir.path().join(format!("ssh_host_{key_type}_key")))
+ .status()
+ .await
+ .context("failed to run ssh-keygen")?;
+ assert!(status.success(), "ssh-keygen failed");
+ }
+
+ let host_keys = HostKeys::from_dir(dir.path(), provider)?;
+ let mut algorithms = Vec::new();
+ for algorithm in host_keys.algorithms() {
+ algorithms.push(algorithm);
+ }
+
+ assert_eq!(algorithms.len(), 2, "unexpected algorithms: {algorithms:?}");
+ assert!(algorithms.contains(&PublicKeyAlgorithm::Ed25519));
+ assert!(algorithms.contains(&PublicKeyAlgorithm::EcdsaSha2Nistp256));
+
+ Ok(())
+}
+
#[tokio::test]
async fn verify_keys() {
let providers = [