Compare commits

...

2 Commits

Author SHA1 Message Date
Dustin d443542ee0 user/login: Add cert to SSH agent
dustin/sshca-cli/pipeline/pr-master There was a failure building this commit Details
dustin/sshca-cli/pipeline/head This commit looks good Details
An SSH certificate is useless on its own, as without the private key,
clients cannot sign servers' authentication requests.  Since `sshca-cli
user login` creates a new key pair each time it is run, the private key
needs to be kept at least as long as the certificate is valid.  To that
end, the command will now add both to the user's SSH agent.  It
communicates with the agent via the UNIX stream socket specified by the
`SSH_AUTH_SOCK` environment variable.

Although there is a Rust crate, [ssh-agent-client-rs][0] that implements
the client side of the SSH agent protocol, it does not support adding
certificates to the agent.  In fact, that functionality is not even
documented in the IETF draft specification for the protocol.  Thus, I
had to figure it out by reading the code of the OpenSSH `ssh-add` tool,
and observing the messages passed between it and `ssh-agent`.

[0]: https://crates.io/crates/ssh-agent-client-rs
2024-01-31 17:41:58 -06:00
Dustin 123ca813a7 user/login: Request signed cert from SSHCA
The `sshca-cli user login` command now requests a signed certificate
from the SSHCA server.  Given a valid OpenID Connect identity token and
an SSH public key, the server will return a signed certificate, valid
for a predetermined (usually short) period of time.  The principals
listed in the certificate are derived from the ID token.
2024-01-31 17:40:14 -06:00
5 changed files with 317 additions and 3 deletions

78
Cargo.lock generated
View File

@ -223,6 +223,16 @@ dependencies = [
"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]]
name = "combine"
version = "4.6.6"
@ -871,6 +881,15 @@ dependencies = [
"serde",
]
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.9.0"
@ -1279,6 +1298,20 @@ dependencies = [
"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]]
name = "password-hash"
version = "0.5.0"
@ -1600,6 +1633,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core",
"sha2",
"signature",
"spki",
"subtle",
@ -1889,6 +1923,48 @@ dependencies = [
"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]]
name = "sshca-cli"
version = "0.1.1"
@ -1903,6 +1979,8 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"ssh-encoding",
"ssh-key",
"tera",
"thiserror",
"tokio",

View File

@ -18,9 +18,11 @@ openidconnect = { version = "3.4.0", default-features = false, features = ["reqw
reqwest = { version = "0.11.22", features = ["multipart"] }
serde = { version = "1.0.190", features = ["derive"] }
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 }
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-subscriber = { version = "0.3.17", features = ["env-filter"] }
uuid = "1.5.0"

133
src/user/agent.rs Normal file
View File

@ -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(())
}

74
src/user/cert.rs Normal file
View File

@ -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)
}

View File

@ -2,11 +2,16 @@
//!
//! The `sshca user` sub-command handles user-based operations, such
//! as signing an SSH user certificate.
mod agent;
mod cert;
mod login;
use std::time::Duration;
use argh::FromArgs;
use ssh_key::rand_core::OsRng;
use ssh_key::{Algorithm, PrivateKey};
use tracing::{debug, error};
use crate::MainResult;
@ -33,8 +38,16 @@ struct LoginArgs {
callback_listen_address: Option<String>,
/// oidc callback timeout, in seconds (default: 300)
#[argh(option, short = 't')]
#[argh(option, short = 'T')]
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`
@ -46,14 +59,28 @@ pub(crate) async fn main(args: Args) -> MainResult {
/// Entry point for `sshca user login`
async fn login(args: LoginArgs) -> MainResult {
let algo = Algorithm::new(&args.key_type)?;
let listen = match args.callback_listen_address {
Some(s) => Some(s.parse()?),
None => None,
};
let timeout = args.callback_timeout.map(Duration::from_secs);
let url = super::get_sshca_server_url()?;
let config = login::get_oidc_config(&url).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(())
}