Compare commits
2 Commits
036304c686
...
40349f5c41
Author | SHA1 | Date |
---|---|---|
|
40349f5c41 | |
|
355a499aa0 |
|
@ -223,6 +223,16 @@ dependencies = [
|
||||||
"windows-targets 0.48.5",
|
"windows-targets 0.48.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.6"
|
version = "4.6.6"
|
||||||
|
@ -871,6 +881,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
|
@ -1279,6 +1298,20 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p521"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"primeorder",
|
||||||
|
"rand_core",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "password-hash"
|
name = "password-hash"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
@ -1600,6 +1633,7 @@ dependencies = [
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
|
"sha2",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
@ -1889,6 +1923,48 @@ dependencies = [
|
||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssh-cipher"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"ssh-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssh-encoding"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"pem-rfc7468",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssh-key"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01f8f4ea73476c0aa5d5e6a75ce1e8634e2c3f82005ef3bbed21547ac57f2bf7"
|
||||||
|
dependencies = [
|
||||||
|
"ed25519-dalek",
|
||||||
|
"p256",
|
||||||
|
"p384",
|
||||||
|
"p521",
|
||||||
|
"rand_core",
|
||||||
|
"rsa",
|
||||||
|
"sec1",
|
||||||
|
"sha2",
|
||||||
|
"signature",
|
||||||
|
"ssh-cipher",
|
||||||
|
"ssh-encoding",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sshca-cli"
|
name = "sshca-cli"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
@ -1903,6 +1979,8 @@ dependencies = [
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ssh-encoding",
|
||||||
|
"ssh-key",
|
||||||
"tera",
|
"tera",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
@ -18,9 +18,11 @@ openidconnect = { version = "3.4.0", default-features = false, features = ["reqw
|
||||||
reqwest = { version = "0.11.22", features = ["multipart"] }
|
reqwest = { version = "0.11.22", features = ["multipart"] }
|
||||||
serde = { version = "1.0.190", features = ["derive"] }
|
serde = { version = "1.0.190", features = ["derive"] }
|
||||||
serde_json = "1.0.108"
|
serde_json = "1.0.108"
|
||||||
|
ssh-encoding = "0.2.0"
|
||||||
|
ssh-key = { version = "0.6.4", features = ["ed25519"] }
|
||||||
tera = { version = "1.19.1", default-features = false }
|
tera = { version = "1.19.1", default-features = false }
|
||||||
thiserror = "1.0.50"
|
thiserror = "1.0.50"
|
||||||
tokio = { version = "1.33.0", features = ["rt", "macros"] }
|
tokio = { version = "1.33.0", features = ["rt", "macros", "net"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||||
uuid = "1.5.0"
|
uuid = "1.5.0"
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! SSH Agent client
|
||||||
|
//!
|
||||||
|
//! The `sshca-cli user login` command will automatically add the
|
||||||
|
//! signed certificate it received from SSHCA to the user's SSH agent.
|
||||||
|
|
||||||
|
use ssh_encoding::{Encode, Writer};
|
||||||
|
use ssh_key::{private::KeypairData, Certificate, PrivateKey};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
|
/// Error type for SSH agent communication
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AgentError {
|
||||||
|
#[error("SSH agent not found: {0}")]
|
||||||
|
NotFound(std::env::VarError),
|
||||||
|
#[error("Could not connect to SSH agent: {0}")]
|
||||||
|
Connect(std::io::Error),
|
||||||
|
#[error("Encoding error: {0}")]
|
||||||
|
Encoding(#[from] ssh_encoding::Error),
|
||||||
|
#[error("Invalid message length: {0}")]
|
||||||
|
IvalidMessageLength(#[from] std::num::TryFromIntError),
|
||||||
|
#[error("Error communicating with SSH agent: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("SSH agent returned failure")]
|
||||||
|
Failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSH Agent message types
|
||||||
|
#[repr(u8)]
|
||||||
|
enum AgentMessageType {
|
||||||
|
AddIdentity = 17,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSH Agent response code
|
||||||
|
enum AgentResponseCode {
|
||||||
|
Success,
|
||||||
|
Failure,
|
||||||
|
InvalidFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u8> for AgentResponseCode {
|
||||||
|
fn from(v: u8) -> Self {
|
||||||
|
// https://github.com/openssh/openssh-portable/blob/V_9_0_P1/authfd.c#L74
|
||||||
|
match v {
|
||||||
|
6 => Self::Success,
|
||||||
|
30 | 102 | 229 => Self::Failure,
|
||||||
|
_ => Self::InvalidFormat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an SSH certificate and private key to the SSH Agent
|
||||||
|
///
|
||||||
|
/// This function adds an SSH certificate and its corresponding private
|
||||||
|
/// key to the SSH Agent.
|
||||||
|
///
|
||||||
|
/// If an error occurs while attempting to add the key to the agent,
|
||||||
|
/// including if no agent is configured, [`AgentError`] is returned.
|
||||||
|
pub async fn add_to_agent(
|
||||||
|
key: &PrivateKey,
|
||||||
|
cert: &Certificate,
|
||||||
|
) -> Result<(), AgentError> {
|
||||||
|
let sock_path =
|
||||||
|
std::env::var("SSH_AUTH_SOCK").map_err(AgentError::NotFound)?;
|
||||||
|
debug!("Connecting to SSH agent at {:?}", sock_path);
|
||||||
|
let mut sock = UnixStream::connect(sock_path)
|
||||||
|
.await
|
||||||
|
.map_err(AgentError::Connect)?;
|
||||||
|
|
||||||
|
trace!("Serializing SSH2_AGENTC_ADD_IDENTITY message");
|
||||||
|
let mut buf: Vec<u8> = vec![];
|
||||||
|
buf.push(AgentMessageType::AddIdentity as u8);
|
||||||
|
serialize_key_cert(key, cert, &mut buf)?;
|
||||||
|
let len = u32::try_from(buf.len())?;
|
||||||
|
|
||||||
|
debug!("Sending key to SSH agent");
|
||||||
|
sock.write_all(&len.to_be_bytes()).await?;
|
||||||
|
sock.write_all(&buf[..]).await?;
|
||||||
|
|
||||||
|
let mut res_len = [0u8; 4];
|
||||||
|
trace!("Waiting for SSH agent response");
|
||||||
|
sock.read_exact(&mut res_len[..]).await?;
|
||||||
|
let res_len = usize::try_from(u32::from_be_bytes(res_len))?;
|
||||||
|
let mut res = vec![0u8; res_len];
|
||||||
|
trace!("Reading {} bytes from SSH agent", res_len);
|
||||||
|
sock.read_exact(&mut res).await?;
|
||||||
|
trace!("Received SSH agent response: {:?}", res);
|
||||||
|
|
||||||
|
match res[0].into() {
|
||||||
|
AgentResponseCode::Success => {
|
||||||
|
info!("Successfully added SSH user certificate to SSH Agent");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(AgentError::Failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize an SSH certificate and private key to send to SSH Agent
|
||||||
|
///
|
||||||
|
/// This function takes a byte buffer and fills it with the wire
|
||||||
|
/// representation of an SSH certificate and its corresponding provate
|
||||||
|
/// key, in order to send them to the SSH Agent.
|
||||||
|
///
|
||||||
|
/// The [draft-miller-ssh-agent-11][0] protocol does not specify how
|
||||||
|
/// certificates are sent to the SSH Agent. The message format used
|
||||||
|
/// here was discovered by reading the OpenSSH portable code,
|
||||||
|
/// specifically [sshkey.c][1], and observing communications between
|
||||||
|
/// `ssh-add` and `ssh-agent`.
|
||||||
|
///
|
||||||
|
/// [0]: https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-11
|
||||||
|
/// [1]: https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshkey.c#L3230
|
||||||
|
fn serialize_key_cert(
|
||||||
|
key: &PrivateKey,
|
||||||
|
cert: &Certificate,
|
||||||
|
buf: &mut impl Writer,
|
||||||
|
) -> Result<(), ssh_encoding::Error> {
|
||||||
|
cert.algorithm().to_certificate_type().encode(buf)?;
|
||||||
|
cert.encode_prefixed(buf)?;
|
||||||
|
match key.key_data() {
|
||||||
|
KeypairData::Dsa(k) => k.encode(buf)?,
|
||||||
|
KeypairData::Ecdsa(k) => k.encode(buf)?,
|
||||||
|
KeypairData::Ed25519(k) => k.encode(buf)?,
|
||||||
|
KeypairData::Encrypted(k) => k.encode(buf)?,
|
||||||
|
KeypairData::Rsa(k) => k.encode(buf)?,
|
||||||
|
KeypairData::SkEcdsaSha2NistP256(k) => k.encode(buf)?,
|
||||||
|
KeypairData::SkEd25519(k) => k.encode(buf)?,
|
||||||
|
KeypairData::Other(k) => k.encode(buf)?,
|
||||||
|
&_ => todo!(),
|
||||||
|
};
|
||||||
|
key.comment().encode(buf)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
//! SSHCA User Certificates
|
||||||
|
//!
|
||||||
|
//! The SSHCA server will sign SSH certificates for a user given a
|
||||||
|
//! supported public key and a valid OIDC Identity token.
|
||||||
|
use reqwest::multipart::{Form, Part};
|
||||||
|
use reqwest::{StatusCode, Url};
|
||||||
|
use ssh_key::{Certificate, PublicKey};
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
/// Error type for issues requesting a signed SSH certificate
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SignError {
|
||||||
|
#[error("{0}")]
|
||||||
|
BadUrl(String),
|
||||||
|
#[error("SSH key error: {0}")]
|
||||||
|
SshKey(#[from] ssh_key::Error),
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("Bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a signed SSH certificate for a given public key
|
||||||
|
///
|
||||||
|
/// This function requests the SSHCA server to sign a certificate for
|
||||||
|
/// the specified SSH public key. The server will use the provided
|
||||||
|
/// identity token to authorize the request and return a certificate
|
||||||
|
/// for the user based on the token subject.
|
||||||
|
///
|
||||||
|
/// If an error occurs while requesting the certificate, [`SignError`]
|
||||||
|
/// is returned.
|
||||||
|
pub async fn sign_key(
|
||||||
|
token: &str,
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
) -> Result<Certificate, SignError> {
|
||||||
|
let url = crate::get_sshca_server_url().map_err(SignError::BadUrl)?;
|
||||||
|
|
||||||
|
let mut url =
|
||||||
|
Url::parse(&url).map_err(|e| SignError::BadUrl(e.to_string()))?;
|
||||||
|
url.path_segments_mut()
|
||||||
|
.map_err(|_| SignError::BadUrl("Invalid URL: missing host".into()))?
|
||||||
|
.pop_if_empty()
|
||||||
|
.push("user")
|
||||||
|
.push("sign");
|
||||||
|
|
||||||
|
let key_str = pubkey.to_openssh()?;
|
||||||
|
let form = Form::new().part("pubkey", Part::text(key_str));
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
info!("Requesting SSH user certificate");
|
||||||
|
debug!("Request: POST {}", url);
|
||||||
|
let res = client
|
||||||
|
.post(url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
debug!("Response: {:?} {}", &res.version(), &res.status());
|
||||||
|
match res.error_for_status_ref() {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(e) if e.status() == Some(StatusCode::BAD_REQUEST) => {
|
||||||
|
let msg = res.text().await.unwrap_or_else(|e| e.to_string());
|
||||||
|
error!("{}: {}", e, msg);
|
||||||
|
return Err(SignError::BadRequest(format!("{}\n{}", e, msg)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let cert = Certificate::from_openssh(&res.text().await?)?;
|
||||||
|
|
||||||
|
Ok(cert)
|
||||||
|
}
|
|
@ -2,11 +2,16 @@
|
||||||
//!
|
//!
|
||||||
//! The `sshca user` sub-command handles user-based operations, such
|
//! The `sshca user` sub-command handles user-based operations, such
|
||||||
//! as signing an SSH user certificate.
|
//! as signing an SSH user certificate.
|
||||||
|
mod agent;
|
||||||
|
mod cert;
|
||||||
mod login;
|
mod login;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use argh::FromArgs;
|
use argh::FromArgs;
|
||||||
|
use ssh_key::rand_core::OsRng;
|
||||||
|
use ssh_key::{Algorithm, PrivateKey};
|
||||||
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use crate::MainResult;
|
use crate::MainResult;
|
||||||
|
|
||||||
|
@ -33,8 +38,16 @@ struct LoginArgs {
|
||||||
callback_listen_address: Option<String>,
|
callback_listen_address: Option<String>,
|
||||||
|
|
||||||
/// oidc callback timeout, in seconds (default: 300)
|
/// oidc callback timeout, in seconds (default: 300)
|
||||||
#[argh(option, short = 't')]
|
#[argh(option, short = 'T')]
|
||||||
callback_timeout: Option<u64>,
|
callback_timeout: Option<u64>,
|
||||||
|
|
||||||
|
/// ssh key type
|
||||||
|
#[argh(option, short = 't', default = "default_key_type()")]
|
||||||
|
key_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_key_type() -> String {
|
||||||
|
"ssh-ed25519".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main entry point for `sshca user`
|
/// Main entry point for `sshca user`
|
||||||
|
@ -46,14 +59,28 @@ pub(crate) async fn main(args: Args) -> MainResult {
|
||||||
|
|
||||||
/// Entry point for `sshca user login`
|
/// Entry point for `sshca user login`
|
||||||
async fn login(args: LoginArgs) -> MainResult {
|
async fn login(args: LoginArgs) -> MainResult {
|
||||||
|
let algo = Algorithm::new(&args.key_type)?;
|
||||||
let listen = match args.callback_listen_address {
|
let listen = match args.callback_listen_address {
|
||||||
Some(s) => Some(s.parse()?),
|
Some(s) => Some(s.parse()?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let timeout = args.callback_timeout.map(Duration::from_secs);
|
let timeout = args.callback_timeout.map(Duration::from_secs);
|
||||||
|
|
||||||
let url = super::get_sshca_server_url()?;
|
let url = super::get_sshca_server_url()?;
|
||||||
let config = login::get_oidc_config(&url).await?;
|
let config = login::get_oidc_config(&url).await?;
|
||||||
let token = login::login(config, listen, timeout).await?;
|
let token = login::login(config, listen, timeout).await?;
|
||||||
println!("{}", token);
|
|
||||||
|
debug!("Generatring new {} key pair", algo);
|
||||||
|
let privkey = PrivateKey::random(&mut OsRng, algo)
|
||||||
|
.map_err(|e| format!("Error generating key pair: {0}", e))?;
|
||||||
|
let pubkey = privkey.public_key();
|
||||||
|
let cert = cert::sign_key(&token, pubkey).await?;
|
||||||
|
|
||||||
|
if let Err(e) = agent::add_to_agent(&privkey, &cert).await {
|
||||||
|
error!("Failed to add certificate to SSH agent: {}", e);
|
||||||
|
if let Ok(c) = cert.to_openssh() {
|
||||||
|
println!("{}", c);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue