diff --git a/config-schema.json b/config-schema.json index 1f1d0f2f1..72e1bedfe 100644 --- a/config-schema.json +++ b/config-schema.json @@ -88,7 +88,8 @@ "inactivity_timeout": "5m", "keepalive_interval": null, "keys": "./data/keys", - "listen": "[::]:2222" + "listen": "[::]:2222", + "temporary_client_certificate_validity": "1m" } }, "sso_providers": { @@ -416,6 +417,10 @@ "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:2222" + }, + "temporary_client_certificate_validity": { + "type": "string", + "default": "1m" } } }, diff --git a/tests/conftest.py b/tests/conftest.py index 861cee0a4..a4150da63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,18 +120,21 @@ def stop(self): pass p.kill() - def start_ssh_server(self, trusted_keys=[], extra_config=""): + def start_ssh_server(self, trusted_keys=[], extra_config="", trusted_ca=[]): port = alloc_port() data_dir = self.ctx.tmpdir / f"sshd-{uuid.uuid4()}" data_dir.mkdir(parents=True) authorized_keys_path = data_dir / "authorized_keys" authorized_keys_path.write_text("\n".join(trusted_keys)) config_path = data_dir / "sshd_config" + ssh_ca = data_dir / "trusted_ca" + ssh_ca.write_text("\n".join(trusted_ca)) config_path.write_text( dedent( f"""\ Port 22 AuthorizedKeysFile {authorized_keys_path} + TrustedUserCAKeys {ssh_ca} AllowAgentForwarding yes AllowTcpForwarding yes GatewayPorts yes @@ -147,9 +150,11 @@ def start_ssh_server(self, trusted_keys=[], extra_config=""): """ ) ) + data_dir.chmod(0o700) authorized_keys_path.chmod(0o600) config_path.chmod(0o600) + ssh_ca.chmod(0o600) self.start( [ diff --git a/tests/images/ssh-server/Dockerfile b/tests/images/ssh-server/Dockerfile index 22db23c4f..acc741fe9 100644 --- a/tests/images/ssh-server/Dockerfile +++ b/tests/images/ssh-server/Dockerfile @@ -1,4 +1,5 @@ FROM alpine:3.14@sha256:0f2d5c38dd7a4f4f733e688e3a6733cb5ab1ac6e3cb4603a5dd564e5bfb80eed RUN apk add openssh curl +RUN adduser -D foo && echo "foo:bar" | chpasswd RUN passwd -u root ENTRYPOINT ["/usr/sbin/sshd", "-De"] diff --git a/tests/test_ssh_target_auth.py b/tests/test_ssh_target_auth.py new file mode 100644 index 000000000..f79f49c80 --- /dev/null +++ b/tests/test_ssh_target_auth.py @@ -0,0 +1,152 @@ +from pathlib import Path +from uuid import uuid4 + +from .api_client import admin_client, sdk +from .conftest import ProcessManager, WarpgateProcess +from .util import wait_port + +USER_PUBLIC_KEY_PATH = Path("ssh-keys/id_ed25519.pub") +USER_PRIVATE_KEY_PATH = "ssh-keys/id_ed25519" + + +class Test: + @staticmethod + def _create_user_role_and_target(api, ssh_port, username: str, auth): + role = api.create_role( + sdk.RoleDataRequest(name=f"role-{uuid4()}"), + ) + user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) + api.create_public_key_credential( + user.id, + sdk.NewPublicKeyCredential( + label="Public Key", + openssh_public_key=USER_PUBLIC_KEY_PATH.read_text().strip(), + ), + ) + api.add_user_role(user.id, role.id) + ssh_target = api.create_target( + sdk.TargetDataRequest( + name=f"ssh-{uuid4()}", + options=sdk.TargetOptions( + sdk.TargetOptionsTargetSSHOptions( + kind="Ssh", + host="localhost", + port=ssh_port, + username=username, + auth=sdk.SSHTargetAuth(auth), + ) + ), + ) + ) + api.add_target_role(ssh_target.id, role.id) + return user, ssh_target + + @staticmethod + def _run_ssh_ls( + processes: ProcessManager, + shared_wg: WarpgateProcess, + user, + ssh_target, + ): + ssh_client = processes.start_ssh_client( + f"{user.username}:{ssh_target.name}@localhost", + "-p", + str(shared_wg.ssh_port), + "-o", + f"IdentityFile={USER_PRIVATE_KEY_PATH}", + "-o", + "PreferredAuthentications=publickey", + "ls", + "/bin/sh", + ) + return ssh_client + + def test_password( + self, + processes: ProcessManager, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server() + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="foo", + auth=sdk.SSHTargetAuthSshTargetPasswordAuth( + kind="Password", + password="bar", + ), + ) + + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" + assert ssh_client.returncode == 0 + + def test_certificate( + self, + processes: ProcessManager, + wg_c_ed25519_pubkey: Path, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server( + trusted_ca=[wg_c_ed25519_pubkey.read_text()] + ) + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="root", + auth=sdk.SSHTargetAuthSshTargetCertificateAuth(kind="Certificate"), + ) + + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" + assert ssh_client.returncode == 0 + + def test_none( + self, + processes: ProcessManager, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server() + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="root", + auth=sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey"), + ) + + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"" + assert ssh_client.returncode != 0 diff --git a/warpgate-common/src/config/defaults.rs b/warpgate-common/src/config/defaults.rs index f8c1cbd27..908a02ce5 100644 --- a/warpgate-common/src/config/defaults.rs +++ b/warpgate-common/src/config/defaults.rs @@ -96,6 +96,10 @@ pub const fn _default_ssh_inactivity_timeout() -> Duration { Duration::from_secs(60 * 5) } +pub const fn _default_temporary_client_certificate_validity() -> Duration { + Duration::from_secs(60) +} + #[allow(clippy::unnecessary_wraps)] pub fn _default_postgres_idle_timeout_str() -> Option { Some("10m".to_string()) diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index 848e08874..50a15d7ef 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -10,7 +10,7 @@ use defaults::{ _default_http_listen, _default_kubernetes_listen, _default_mysql_listen, _default_postgres_listen, _default_recordings_path, _default_retention, _default_session_max_age, _default_ssh_inactivity_timeout, _default_ssh_keys_path, - _default_ssh_listen, + _default_ssh_listen, _default_temporary_client_certificate_validity, }; use poem::http::uri; use poem_openapi::{Object, Union}; @@ -307,6 +307,13 @@ pub struct SshConfig { #[serde(default)] pub keepalive_interval: Option, + + #[serde( + default = "_default_temporary_client_certificate_validity", + with = "humantime_serde" + )] + #[schemars(with = "String")] + pub temporary_client_certificate_validity: Duration, } impl Default for SshConfig { @@ -320,6 +327,7 @@ impl Default for SshConfig { external_host: None, inactivity_timeout: _default_ssh_inactivity_timeout(), keepalive_interval: None, + temporary_client_certificate_validity: _default_temporary_client_certificate_validity(), } } } diff --git a/warpgate-common/src/config/target.rs b/warpgate-common/src/config/target.rs index 262fa49ca..f215de854 100644 --- a/warpgate-common/src/config/target.rs +++ b/warpgate-common/src/config/target.rs @@ -47,6 +47,8 @@ pub enum SSHTargetAuth { Password(SshTargetPasswordAuth), #[serde(rename = "publickey")] PublicKey(SshTargetPublicKeyAuth), + #[serde(rename = "certificate")] + Certificate(SshTargetCertificateAuth), #[serde(rename = "iam_role")] IamRole(SshTargetIamRoleAuth), } @@ -59,6 +61,9 @@ pub struct SshTargetPasswordAuth { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] pub struct SshTargetPublicKeyAuth {} +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] +pub struct SshTargetCertificateAuth {} + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] pub struct SshTargetIamRoleAuth {} diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index 1c97fa531..5ff159092 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -43,6 +43,7 @@ mod m00038_fix_target_auth_tags; mod m00039_show_session_menu; mod m00040_allowed_ip_range; mod m00041_fix_user_role_assignment_dates; +mod m00042_target_ssh_cert; pub struct Migrator; @@ -91,6 +92,7 @@ impl MigratorTrait for Migrator { Box::new(m00039_show_session_menu::Migration), Box::new(m00040_allowed_ip_range::Migration), Box::new(m00041_fix_user_role_assignment_dates::Migration), + Box::new(m00042_target_ssh_cert::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00042_target_ssh_cert.rs b/warpgate-db-migrations/src/m00042_target_ssh_cert.rs new file mode 100644 index 000000000..580440a6a --- /dev/null +++ b/warpgate-db-migrations/src/m00042_target_ssh_cert.rs @@ -0,0 +1,105 @@ +use sea_orm::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, JsonValue, QueryFilter, Set}; +use sea_orm_migration::prelude::*; + +use crate::m00007_targets_and_roles::target; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m00042_target_ssh_cert" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // Find SSH targets + let ssh_targets = target::Entity::find() + .filter(target::Column::Kind.eq(target::TargetKind::Ssh)) + .all(conn) + .await?; + + for target in ssh_targets { + let mut options = target + .options + .get("ssh") + .unwrap_or_default() + .clone(); + + if let Some(auth) = options.get_mut("auth") { + if auth.get("kind").is_some() { + continue; + } else if let Some(password) = auth.get("password").cloned() { + *auth = serde_json::json!({ + "kind": "password", + "password": password, + }); + } else { + *auth = serde_json::json!({ + "kind": "publickey", + }); + } + + let mut target: target::ActiveModel = target.into(); + let options = serde_json::json!({"ssh": options}); + target.options = Set(options); + target.update(conn).await?; + } + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // Find SSH targets + let ssh_targets = target::Entity::find() + .filter(target::Column::Kind.eq(target::TargetKind::Ssh)) + .all(conn) + .await?; + + // Check if there is a target authenticated by a certificate + let has_certificates = ssh_targets + .iter() + .filter_map(|t| t.options.get("ssh")) + .filter_map(|t| t.get("auth")) + .any(|t| { + let value = JsonValue::String("certificate".to_string()); + t.get("kind") == Some(&value) + }); + + if has_certificates { + // At least one target is using certificate auth + // reversing would fallback to pubkey, which would not work + assert!(!has_certificates, "This migration cannot be reversed"); + } + + for target in ssh_targets { + let mut options = target + .options + .get("ssh") + .unwrap_or(Default::default()) + .clone(); + + if let Some(auth) = options.get_mut("auth") { + if let Some(password) = auth.get("password").cloned() { + *auth = serde_json::json!({ + "password": password, + }); + } else { + *auth = serde_json::json!({}); + } + + let mut target: target::ActiveModel = target.into(); + let options = serde_json::json!({"ssh": options}); + target.options = Set(options); + target.update(conn).await?; + } + } + Ok(()) + } +} diff --git a/warpgate-protocol-ssh/src/client/mod.rs b/warpgate-protocol-ssh/src/client/mod.rs index 3f15167be..5158284f4 100644 --- a/warpgate-protocol-ssh/src/client/mod.rs +++ b/warpgate-protocol-ssh/src/client/mod.rs @@ -30,7 +30,10 @@ use warpgate_core::Services; use self::handler::ClientHandlerEvent; use super::{ChannelOperation, DirectTCPIPParams}; use crate::client::handler::ClientHandlerError; -use crate::{load_keys, load_preferred_key, ForwardedStreamlocalParams, ForwardedTcpIpParams}; +use crate::{ + generate_private, issue_temporary_client_certificate, load_keys, load_preferred_key, + ForwardedStreamlocalParams, ForwardedTcpIpParams, +}; #[derive(Debug, thiserror::Error)] pub enum ConnectionError { @@ -422,6 +425,103 @@ impl RemoteClient { Ok(false) } + fn get_insecure_preferred() -> Preferred { + Preferred { + kex: Cow::Borrowed(&[ + kex::MLKEM768X25519_SHA256, + kex::CURVE25519, + kex::CURVE25519_PRE_RFC_8731, + kex::ECDH_SHA2_NISTP256, + kex::ECDH_SHA2_NISTP384, + kex::ECDH_SHA2_NISTP521, + kex::DH_G16_SHA512, + kex::DH_G14_SHA256, // non-default + kex::DH_GEX_SHA256, + kex::DH_G1_SHA1, // non-default + kex::EXTENSION_SUPPORT_AS_CLIENT, + kex::EXTENSION_SUPPORT_AS_SERVER, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, + ]), + key: Cow::Borrowed(&[ + russh::keys::Algorithm::Ed25519, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP256, + }, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP384, + }, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP521, + }, + russh::keys::Algorithm::Rsa { + hash: Some(russh::keys::HashAlg::Sha256), + }, + russh::keys::Algorithm::Rsa { + hash: Some(russh::keys::HashAlg::Sha512), + }, + russh::keys::Algorithm::Rsa { hash: None }, + ]), + cipher: Cow::Borrowed(&[ + russh::cipher::CHACHA20_POLY1305, + russh::cipher::AES_256_GCM, + russh::cipher::AES_256_CTR, + russh::cipher::AES_256_CBC, + russh::cipher::AES_192_CTR, + russh::cipher::AES_192_CBC, + russh::cipher::AES_128_CTR, + russh::cipher::AES_128_CBC, + russh::cipher::TRIPLE_DES_CBC, + ]), + ..<_>::default() + } + } + + async fn connect_certificate( + &self, + ssh_options: &TargetSSHOptions, + session: &mut Handle, + ) -> Result { + let config = self.services.config.lock().await.clone(); + let cert_validity = config.store.ssh.temporary_client_certificate_validity; + let keys = load_keys(&config, &self.services.global_params, "client")?; + for key in keys { + // Generate a certificate signed by private key + let client_key = generate_private(key.algorithm()).map_err(russh::Error::from)?; + let key_str = client_key + .public_key() + .to_openssh() + .map_err(russh::Error::from)?; + let certificate = issue_temporary_client_certificate( + &ssh_options.username, + &PublicKey::from(&client_key), + &key, + cert_validity, + )?; + + let response = session + .authenticate_openssh_cert( + ssh_options.username.clone(), + Arc::new(client_key), + certificate, + ) + .await?; + + if self + ._handle_auth_result(session, ssh_options.username.clone(), response) + .await + .unwrap_or(false) + { + debug!( + username=&ssh_options.username[..], + key=%key_str, + "Authenticated with certificate"); + return Ok(true); + } + } + Ok(false) + } + async fn connect(&mut self, ssh_options: TargetSSHOptions) -> Result<(), ConnectionError> { let address_str = format!("{}:{}", ssh_options.host, ssh_options.port); let address = match address_str @@ -439,55 +539,7 @@ impl RemoteClient { info!(?address, username = &ssh_options.username[..], "Connecting"); let algos = if ssh_options.allow_insecure_algos.unwrap_or(false) { - Preferred { - kex: Cow::Borrowed(&[ - kex::MLKEM768X25519_SHA256, - kex::CURVE25519, - kex::CURVE25519_PRE_RFC_8731, - kex::ECDH_SHA2_NISTP256, - kex::ECDH_SHA2_NISTP384, - kex::ECDH_SHA2_NISTP521, - kex::DH_G16_SHA512, - kex::DH_G14_SHA256, // non-default - kex::DH_GEX_SHA256, - kex::DH_G1_SHA1, // non-default - kex::EXTENSION_SUPPORT_AS_CLIENT, - kex::EXTENSION_SUPPORT_AS_SERVER, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, - ]), - key: Cow::Borrowed(&[ - russh::keys::Algorithm::Ed25519, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP256, - }, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP384, - }, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP521, - }, - russh::keys::Algorithm::Rsa { - hash: Some(russh::keys::HashAlg::Sha256), - }, - russh::keys::Algorithm::Rsa { - hash: Some(russh::keys::HashAlg::Sha512), - }, - russh::keys::Algorithm::Rsa { hash: None }, - ]), - cipher: Cow::Borrowed(&[ - russh::cipher::CHACHA20_POLY1305, - russh::cipher::AES_256_GCM, - russh::cipher::AES_256_CTR, - russh::cipher::AES_256_CBC, - russh::cipher::AES_192_CTR, - russh::cipher::AES_192_CBC, - russh::cipher::AES_128_CTR, - russh::cipher::AES_128_CBC, - russh::cipher::TRIPLE_DES_CBC, - ]), - ..<_>::default() - } + Self::get_insecure_preferred() } else { Preferred::default() }; @@ -625,6 +677,12 @@ impl RemoteClient { auth_error_msg = Some("Public key authentication was rejected by the SSH target".into()); } } + SSHTargetAuth::Certificate(_) => { + auth_result = self.connect_certificate(&ssh_options, &mut session).await?; + if !auth_result { + auth_error_msg = Some("Certificate authentication was rejected by the SSH target".into()); + } + } SSHTargetAuth::IamRole(_) => { let instance_info = warpgate_aws::find_instance_by_ip(&ssh_options.host).await?; diff --git a/warpgate-protocol-ssh/src/keys.rs b/warpgate-protocol-ssh/src/keys.rs index 8c9c24ab1..1eed1efce 100644 --- a/warpgate-protocol-ssh/src/keys.rs +++ b/warpgate-protocol-ssh/src/keys.rs @@ -1,8 +1,10 @@ use std::fs::{create_dir_all, File}; use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use russh::keys::{encode_pkcs8_pem, load_secret_key, HashAlg, PrivateKey}; +use russh::keys::ssh_key::certificate; +use russh::keys::{encode_pkcs8_pem, load_secret_key, Certificate, HashAlg, PrivateKey, PublicKey}; use tracing::*; use warpgate_common::helpers::fs::{secure_directory, secure_file}; use warpgate_common::helpers::rng::get_crypto_rng; @@ -14,6 +16,10 @@ fn get_keys_path(config: &WarpgateConfig, params: &GlobalParams) -> PathBuf { path } +pub fn generate_private(algo: russh::keys::Algorithm) -> Result { + Ok(PrivateKey::random(&mut get_crypto_rng(), algo)?) +} + pub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &str) -> Result<()> { let path = get_keys_path(config, params); create_dir_all(&path)?; @@ -33,8 +39,7 @@ pub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &st let key_path = path.join(name); if !key_path.exists() { info!("Generating {prefix} key ({algo:?})"); - let key = PrivateKey::random(&mut get_crypto_rng(), algo) - .context("Failed to generate key")?; + let key = generate_private(algo).context("Failed to generate key")?; let f = File::create(&key_path)?; encode_pkcs8_pem(&key, f)?; } @@ -58,6 +63,37 @@ pub fn load_keys( ]) } +pub fn issue_temporary_client_certificate( + user: &str, + public_key: &PublicKey, + signing_key: &PrivateKey, + validity: Duration, +) -> Result { + // Backdate slightly to tolerate modest clock skew between Warpgate and targets. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| russh::keys::Error::SshKey(e.into()))? + .as_secs(); + let valid_after = now.saturating_sub(30); + let valid_before = now.saturating_add(validity.as_secs().max(1)); + + let mut cert_builder = certificate::Builder::new_with_random_nonce( + &mut get_crypto_rng(), + public_key, + valid_after, + valid_before, + )?; + + cert_builder.cert_type(certificate::CertType::User)?; + cert_builder.valid_principal(user)?; + cert_builder.extension("permit-agent-forwarding", "")?; + cert_builder.extension("permit-port-forwarding", "")?; + cert_builder.extension("permit-pty", "")?; + cert_builder.extension("permit-X11-forwarding", "")?; + + Ok(cert_builder.sign(signing_key)?) +} + pub fn load_preferred_key( config: &WarpgateConfig, params: &GlobalParams, diff --git a/warpgate-web/src/admin/config/SSHKeys.svelte b/warpgate-web/src/admin/config/SSHKeys.svelte index b4f55108b..857ce0aea 100644 --- a/warpgate-web/src/admin/config/SSHKeys.svelte +++ b/warpgate-web/src/admin/config/SSHKeys.svelte @@ -49,6 +49,18 @@ {/each} +

Warpgate's SSH CA keys

+ Add these keys to the file referenced by your targets' TrustedUserCAKeys +
+ {#each ownKeys as key (key)} +
+
{key.kind} {key.publicKeyBase64}
+
+ +
+
+ {/each} +
{/if}
diff --git a/warpgate-web/src/admin/config/targets/ssh/Options.svelte b/warpgate-web/src/admin/config/targets/ssh/Options.svelte index a01f86f11..da7e9cb16 100644 --- a/warpgate-web/src/admin/config/targets/ssh/Options.svelte +++ b/warpgate-web/src/admin/config/targets/ssh/Options.svelte @@ -62,13 +62,14 @@ - {#if options.auth.kind === 'PublicKey'} + {#if ['PublicKey', 'Certificate'].includes(options.auth.kind)}