Compare commits

..

6 Commits

Author SHA1 Message Date
Dustin 23c57305bc Merge branch 'feature/user-certs'
dustin/sshca-cli/pipeline/head There was a failure building this commit Details
2024-01-31 17:54:28 -06:00
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
Dustin 3b55f7418e user: Add sshca user login command
The `sshca user login` command will eventually provide the command-line
interface for obtaining user SSH certificates.  It initiates the OAuth2
login process, retreiving an OpenID Connect Identity Token from the
OpenID Server.  This token will be submitted to the SSHCA server to
authorize a request to sign a certificate.  For now, though, the token
is printed to standard output, e.g. to be used in a `curl` request.
2023-11-21 20:13:17 -06:00
Dustin c26d67a25b main: Factor out get_sshca_server_url function
The `get_sshca_server_url` function encapsulates the logic of
identifying the URL of the SSHCA server.  For now, it only considers the
`SSHCA_SERVER` environment variable, but eventually, it will also
support other configuration methods like a configuration file.  Moving
this to a separate function will allow other areas of the code to share
the same logic.
2023-11-20 18:23:35 -06:00
Dustin 1d0e558163 Add SSHCA_CLI_DEBUG_TEST_MACHINE_ID env var
When running a debug build, the `sshca host sign` command will now check
the `SSHCA_CLI_DEBUG_TEST_MACHINE_ID` environment variable for the value
to use as the machine ID.  This is useful during development and
testing, where the real machine ID is inaccessible or otherwise
unavailable.

The `SSHCA_CLI_DEBUG_TEST_MACHINE_ID` environment variable is *NOT* used
at all in release builds.
2023-11-16 20:12:38 -06:00
9 changed files with 1972 additions and 33 deletions

View File

@ -13,3 +13,8 @@ max_line_length = 79
max_line_length = 79
indent_style = space
indent_size = 4
[*.html]
max_line_length = 79
indent_style = space
indent_size = 2

1298
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,14 +10,23 @@ edition = "2021"
[dependencies]
argh = "0.1.12"
argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] }
form_urlencoded = "1.2.0"
gethostname = "0.4.3"
hyper = { version = "0.14", features = ["server"] }
jsonwebtoken = { version = "9.1.0", default-features = false }
openidconnect = { version = "3.4.0", default-features = false, features = ["reqwest", "native-tls"] }
reqwest = { version = "0.11.22", default-features = false, features = ["multipart"] }
serde = { version = "1.0.190", features = ["derive"] }
tokio = { version = "1.33.0", features = ["rt", "macros"] }
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", "net"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
uuid = "1.5.0"
webbrowser = "0.8.12"
[features]
default = ["native-tls"]

View File

@ -1,3 +1,5 @@
mod user;
use std::path::PathBuf;
use std::time;
@ -25,6 +27,7 @@ struct Args {
#[argh(subcommand)]
enum Subcommand {
Host(HostArgs),
User(user::Args),
}
/// Manage host keys and certificates
@ -86,6 +89,7 @@ async fn inner_main() -> MainResult {
let args: Args = argh::from_env();
match args.command {
Subcommand::Host(args) => host_cmd(args).await,
Subcommand::User(args) => user::main(args).await,
}
}
@ -96,15 +100,7 @@ async fn host_cmd(args: HostArgs) -> MainResult {
}
async fn sign_key(args: SignArgs) -> MainResult {
let url = match std::env::var("SSHCA_SERVER") {
Ok(v) => v,
Err(std::env::VarError::NotPresent) => {
return Err("SSHCA_SERVER environment variable is not set".into());
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err("SSHCA_SERVER environment variable is invalid".into());
}
};
let url = get_sshca_server_url()?;
let Some(hostname) = get_hostname() else {
return Err("Hostname must be valid UTF-8".into());
};
@ -175,11 +171,30 @@ async fn sign_key(args: SignArgs) -> MainResult {
Ok(())
}
fn get_sshca_server_url() -> Result<String, String> {
match std::env::var("SSHCA_SERVER") {
Ok(v) => Ok(v),
Err(std::env::VarError::NotPresent) => {
Err("SSHCA_SERVER environment variable is not set".into())
}
Err(std::env::VarError::NotUnicode(_)) => {
Err("SSHCA_SERVER environment variable is invalid".into())
}
}
}
fn get_hostname() -> Option<String> {
gethostname::gethostname().into_string().ok()
}
fn get_machine_id() -> Option<Uuid> {
#[cfg(debug_assertions)]
if let Ok(v) = std::env::var("SSHCA_CLI_DEBUG_TEST_MACHINE_ID") {
if let Ok(u) = Uuid::parse_str(&v) {
return Some(u);
}
};
match std::fs::read_to_string(RPI_SERIAL_PATH) {
Ok(s) => match Uuid::parse_str(&format!(
"{:0>32}",

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

40
src/user/callback.html Normal file
View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SSHCA Login</title>
<style>
html, body {
background-color: #fbfaf5;
color: #000000;
font-family: sans-serif;
font-size: 14pt;
}
.error {
color: #ff0000;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #1c1b22;
color: #dadade
}
}
</style>
</head>
<body>
{% if error %}
<h1>Login Error</h1>
<p class="error">
{{ error }}
</p>
{% else %}
<h1>Login Successful</h1>
<p>You may now close this window.</p>
<script>
setTimeout(window.close, 2000);
</script>
{% endif %}
</body>

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

325
src/user/login.rs Normal file
View File

@ -0,0 +1,325 @@
//! OpenID Connect User Authentication
//!
//! The SSHCA server uses OIDC Identity Tokens to authorize users in
//! order to issue SSH user certificates.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use hyper::service;
use hyper::{Body, Request, Response, Server};
use openidconnect::core::{
CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
CoreProviderMetadata,
};
use openidconnect::reqwest::async_http_client;
use openidconnect::reqwest::Error as OidcReqwestError;
use openidconnect::url::ParseError;
use openidconnect::{
AccessTokenHash, AuthorizationCode, ClaimsVerificationError, ClientId,
ClientSecret, CsrfToken, DiscoveryError, IssuerUrl, Nonce,
PkceCodeChallenge, RedirectUrl, RequestTokenError, Scope, SigningError,
StandardErrorResponse,
};
use openidconnect::{OAuth2TokenResponse, TokenResponse};
use reqwest::Url;
use serde::Deserialize;
use tera::{Context, Tera};
use tokio::sync::mpsc;
use tokio::sync::Notify;
use tokio::time::error::Elapsed;
use tracing::{debug, error, info, trace};
/// Error type for issues during login/authentication
#[derive(Debug, thiserror::Error)]
pub enum LoginError {
#[error("SSHCA Server not configured for OpenID Connect authorization")]
OidcNotConfigured,
#[error("Invalid OIDC IdP URL: cannot be a base")]
UnsupportedUrl,
#[error("Invalid OIDC IdP URL: {0}")]
UrlParse(#[from] ParseError),
#[error("HTTP request error: {0}")]
Request(#[from] reqwest::Error),
#[error("Failed to parse JSON document: {0}")]
JsonParse(#[from] serde_json::Error),
#[error("OIDC discovery failed: {0}")]
OidcDiscovery(#[from] DiscoveryError<OidcReqwestError<reqwest::Error>>),
#[error("Token request error: {0}")]
TokenRequestError(
#[from]
RequestTokenError<
OidcReqwestError<reqwest::Error>,
StandardErrorResponse<CoreErrorResponseType>,
>,
),
#[error("Server did not return an ID token")]
MissingIdToken,
#[error("Server returned an ID token")]
InvalidIdToken,
#[error("Invalid token claims: {0}")]
ClaimsVerificationError(#[from] ClaimsVerificationError),
#[error("Token signature error: {0}")]
SigningError(#[from] SigningError),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Missing state parameter")]
MissingStateParam,
#[error("Invalid state parameter")]
InvalidCsrfState,
#[error("Missing OAuth2 authorization code")]
MissingAuthCode,
#[error("{0}")]
IdpError(String),
#[error("Timed out waiting for OAuth2 authorization callback")]
Timeout(#[from] Elapsed),
}
/// OpenID Connect Client Configuration
///
/// All fields are optional, as the server may not be configured for
/// OIDC authorization.
#[derive(Deserialize)]
pub struct OidcConfig {
/// OIDC IdP base URL
url: Option<String>,
/// OAuth2 client ID
client_id: Option<String>,
/// OAuth2 client secret
client_secret: Option<String>,
}
/// Retrieve OpenID Connect client configuration from SSHCA server
///
/// The SSHCA server provides the necessary configuration values for
/// contacting the OpenID Provider. This function retrieves those
/// values from the server, returning an [`OidcConfig`] structure that
/// can be passed to [`login`].
///
/// If an error occurs communicating with the server, [`LoginError`]
/// is returned.
pub async fn get_oidc_config(url: &str) -> Result<OidcConfig, LoginError> {
let mut url = Url::parse(url)?;
url.path_segments_mut()
.map_err(|_| LoginError::UnsupportedUrl)?
.pop_if_empty()
.push("user")
.push("oidc-config");
let client = reqwest::Client::new();
info!("Fetching SSHCA OIDC configuration");
debug!("Request: GET {}", url);
let res = client.get(url).send().await?;
debug!("Response: {:?} {}", &res.version(), &res.status());
res.error_for_status_ref()?;
Ok(serde_json::from_str(&res.text().await?)?)
}
/// Log in with the OIDC IdP and return an identity token
///
/// This function performs the OAuth2 login process, requesting an
/// identity token from the OpenID Provider.
///
/// The OAuth2 login process requires user interaction via a web
/// browser. If possible, this function will launch a browser and
/// navigate to the OIDC authorization URL. If the browser could not
/// be launched automatically, the authorization URL is printed to
/// standard error, where the user must click it or copy & paste it
/// into a browser manually.
///
/// After initiating the login process, this function starts an HTTP
/// server (usually listening on the loopback interface), in order to
/// handle the request from the IdP after the user has successfully
/// logged in. The request will contain an OAuth2 Authorization Code,
/// which will be converted into an OIDC Identity Token by making an
/// HTTP request to the IdP directly.
pub async fn login(
config: OidcConfig,
listen: Option<SocketAddr>,
timeout: Option<Duration>,
) -> Result<String, LoginError> {
if config.url.is_none() || config.client_id.is_none() {
return Err(LoginError::OidcNotConfigured);
}
let oidc_url = config.url.unwrap();
let client_id = config.client_id.unwrap();
let listen = listen.unwrap_or(([127, 0, 0, 1], 8976).into());
let timeout = timeout.unwrap_or_else(|| Duration::from_secs(300));
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(oidc_url)?,
async_http_client,
)
.await?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(client_id),
config.client_secret.map(ClientSecret::new),
)
.set_redirect_uri(RedirectUrl::new(format!("http://{}", listen))?);
let (pkce_challenge, pkce_verifier) =
PkceCodeChallenge::new_random_sha256();
trace!("PKCE: {:?} {:?}", pkce_challenge, pkce_verifier);
let (auth_url, csrf_token, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_challenge)
.add_scope(Scope::new("openid".into()))
.add_scope(Scope::new("profile".into()))
.add_scope(Scope::new("email".into()))
.add_scope(Scope::new("groups".into()))
.url();
trace!(
"CSRF token: {}, nonce: {}",
csrf_token.secret(),
nonce.secret()
);
let srv = tokio::spawn(run_server(listen, csrf_token, timeout));
if let Err(e) = webbrowser::open(auth_url.as_str()) {
eprintln!("Could not open web browser: {}", e);
eprintln!("Browse to: {}", auth_url);
}
let code = srv.await.unwrap()?;
trace!("Got authorization code: {}", code);
info!("Exchanging authorization code for access token");
let token_response = client
.exchange_code(AuthorizationCode::new(code))
.set_pkce_verifier(pkce_verifier)
.request_async(async_http_client)
.await?;
debug!("Received response token type {:?}", token_response.token_type());
debug!("Access token: {}", token_response.access_token().secret());
trace!("Token response: {:?}", token_response);
let id_token = token_response
.id_token()
.ok_or(LoginError::MissingIdToken)?;
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
&id_token.signing_alg()?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(LoginError::InvalidIdToken);
}
}
Ok(id_token.to_string())
}
/// Start HTTP server for OAuth2 callback
///
/// After the user logs in, the OAuth2 IdP redirects the browser to the
/// URL provided in the authorization request. This function starts
/// an HTTP server, listening on the specified socket address (usually
/// some port on the loopback interface) to receive the callback
/// request. Only a single request is handled, after which the server
/// is stopped and the OAuth2 Authorization Code included in the
/// query string of the request is parsed and returned.
///
/// If an error occurs while running the server or handling the request,
/// [`LoginError`] is returned.
async fn run_server(
listen: SocketAddr,
csrf_token: CsrfToken,
timeout: Duration,
) -> Result<String, LoginError> {
let csrf_token = Arc::new(csrf_token);
let (tx, mut rx) = mpsc::channel(1);
let notify = Arc::new(Notify::new());
let notifier = notify.clone();
let svc = service::make_service_fn(move |_| {
let csrf_token = csrf_token.clone();
let result = tx.clone();
let notifier = notifier.clone();
async move {
Ok::<_, hyper::Error>(service::service_fn(move |req| {
debug!("Handling HTTP request");
let csrf_token = csrf_token.clone();
let result = result.clone();
let notifier = notifier.clone();
async move {
let mut ctx = Context::new();
match handle_callback(req, &csrf_token).await {
Ok(s) => {
result.send(Ok(s)).await.unwrap();
}
Err(e) => {
ctx.insert("error", &e.to_string());
result.send(Err(e)).await.unwrap();
}
};
let mut tera = Tera::default();
tera.add_raw_template(
"callback",
include_str!("callback.html"),
)
.unwrap();
let res = tera.render("callback", &ctx).unwrap();
notifier.notify_one();
Ok::<_, hyper::Error>(Response::new(Body::from(res)))
}
}))
}
});
debug!("Starting HTTP server on {}", listen);
let server = tokio::spawn(
Server::bind(&listen)
.serve(svc)
.with_graceful_shutdown(async move { notify.notified().await }),
);
info!("Waiting for callback request");
let code = tokio::time::timeout(timeout, rx.recv()).await?.unwrap();
let _ = server.await.unwrap();
code
}
/// HTTP request handler for OAuth2 authorization callbacks
///
/// This function validates the CSRF token and parses the OAuth2
/// Authorization Code from the callback request.
async fn handle_callback(
req: Request<Body>,
csrf_token: &CsrfToken,
) -> Result<String, LoginError> {
let query = req
.uri()
.query()
.ok_or(LoginError::InvalidRequest("Missing query string".into()))?;
let params: HashMap<_, _> =
form_urlencoded::parse(query.as_bytes()).collect();
let state = params.get("state").ok_or(LoginError::MissingStateParam)?;
if state != csrf_token.secret() {
return Err(LoginError::InvalidCsrfState);
}
if let Some(error) = params.get("error") {
let msg = if let Some(err_desc) = params.get("error_description") {
format!("Error handling ODIC callback ({}): {}", error, err_desc,)
} else {
format!("Error handling OIDC callback: {}", error)
};
error!("{}", msg);
return Err(LoginError::IdpError(msg));
}
let code = params.get("code").ok_or(LoginError::MissingAuthCode)?;
info!("Received OAuth2 authorization code");
Ok(code.to_string())
}

86
src/user/mod.rs Normal file
View File

@ -0,0 +1,86 @@
//! CLI module for user features
//!
//! 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;
/// Manage host keys and certificates
#[derive(FromArgs)]
#[argh(subcommand, name = "user")]
pub(crate) struct Args {
#[argh(subcommand)]
command: UserSubcommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum UserSubcommand {
Login(LoginArgs),
}
/// Log in and obtain an SSH user certificate
#[derive(FromArgs)]
#[argh(subcommand, name = "login")]
struct LoginArgs {
/// listen socket address for OIDC callback (default: 127.0.0.1:8976)
#[argh(option, short = 'l')]
callback_listen_address: Option<String>,
/// oidc callback timeout, in seconds (default: 300)
#[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`
pub(crate) async fn main(args: Args) -> MainResult {
match args.command {
UserSubcommand::Login(args) => login(args).await,
}
}
/// 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?;
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(())
}