Merge branch 'feature/user-certs'
dustin/sshca-cli/pipeline/head There was a failure building this commit
Details
dustin/sshca-cli/pipeline/head There was a failure building this commit
Details
commit
23c57305bc
|
@ -13,3 +13,8 @@ max_line_length = 79
|
||||||
max_line_length = 79
|
max_line_length = 79
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
max_line_length = 79
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
11
Cargo.toml
|
@ -10,14 +10,23 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argh = "0.1.12"
|
argh = "0.1.12"
|
||||||
argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] }
|
argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] }
|
||||||
|
form_urlencoded = "1.2.0"
|
||||||
gethostname = "0.4.3"
|
gethostname = "0.4.3"
|
||||||
|
hyper = { version = "0.14", features = ["server"] }
|
||||||
jsonwebtoken = { version = "9.1.0", default-features = false }
|
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"] }
|
reqwest = { version = "0.11.22", default-features = false, features = ["multipart"] }
|
||||||
serde = { version = "1.0.190", features = ["derive"] }
|
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 = "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"
|
||||||
|
webbrowser = "0.8.12"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["native-tls"]
|
default = ["native-tls"]
|
||||||
|
|
33
src/main.rs
33
src/main.rs
|
@ -1,3 +1,5 @@
|
||||||
|
mod user;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time;
|
use std::time;
|
||||||
|
|
||||||
|
@ -25,6 +27,7 @@ struct Args {
|
||||||
#[argh(subcommand)]
|
#[argh(subcommand)]
|
||||||
enum Subcommand {
|
enum Subcommand {
|
||||||
Host(HostArgs),
|
Host(HostArgs),
|
||||||
|
User(user::Args),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manage host keys and certificates
|
/// Manage host keys and certificates
|
||||||
|
@ -86,6 +89,7 @@ async fn inner_main() -> MainResult {
|
||||||
let args: Args = argh::from_env();
|
let args: Args = argh::from_env();
|
||||||
match args.command {
|
match args.command {
|
||||||
Subcommand::Host(args) => host_cmd(args).await,
|
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 {
|
async fn sign_key(args: SignArgs) -> MainResult {
|
||||||
let url = match std::env::var("SSHCA_SERVER") {
|
let url = get_sshca_server_url()?;
|
||||||
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 Some(hostname) = get_hostname() else {
|
let Some(hostname) = get_hostname() else {
|
||||||
return Err("Hostname must be valid UTF-8".into());
|
return Err("Hostname must be valid UTF-8".into());
|
||||||
};
|
};
|
||||||
|
@ -175,11 +171,30 @@ async fn sign_key(args: SignArgs) -> MainResult {
|
||||||
Ok(())
|
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> {
|
fn get_hostname() -> Option<String> {
|
||||||
gethostname::gethostname().into_string().ok()
|
gethostname::gethostname().into_string().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_machine_id() -> Option<Uuid> {
|
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) {
|
match std::fs::read_to_string(RPI_SERIAL_PATH) {
|
||||||
Ok(s) => match Uuid::parse_str(&format!(
|
Ok(s) => match Uuid::parse_str(&format!(
|
||||||
"{:0>32}",
|
"{:0>32}",
|
||||||
|
|
|
@ -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,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>
|
|
@ -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)
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
Loading…
Reference in New Issue