Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["api", "client", "common", "server", "tests", "xtask"]
members = ["api", "client", "common", "proxy", "server", "tests", "xtask"]
resolver = "2"

[workspace.package]
Expand Down Expand Up @@ -64,8 +64,8 @@ signature = "2"
sled-hardware-types = { git = "https://github.com/oxidecomputer/omicron", tag = "rel/v21/rc1" }
slog = "2"
slog-term = "2"
sprockets-tls = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "98615b86d51f74ce220198eb16c57932fd24e7cb", default-features = false }
sprockets-tls-test-utils = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "98615b86d51f74ce220198eb16c57932fd24e7cb" }
sprockets-tls = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "02c7e5414d97c0c85d629cb76d1f6beab73d960e", default-features = false }
sprockets-tls-test-utils = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "02c7e5414d97c0c85d629cb76d1f6beab73d960e" }
ssh-key = { version = "0.6", features = ["ed25519", "p256", "serde"] }
strum = { version = "0.27", features = ["derive"] }
tempfile = "3"
Expand Down
59 changes: 29 additions & 30 deletions client/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ use crate::repl::Repl;
use crate::tls;
use crate::tunnel::{Tunnel, TunnelError};
use crate::types::{Error as ApiError, SessionStartBody};
use crate::{Client, Error as ClientError};
use crate::{AuthzSigner, Client, Error as ClientError};

// Names of environment variables for argument defaults
// (to prevent mispellings).
Expand Down Expand Up @@ -888,7 +888,6 @@ impl ClientCommand {
async fn authenticate<E>(
ctx: &mut impl CommandContext,
client: &Client,
via: Option<&BaseboardId>,
response: ResponseValue<E>,
) -> Result<(Identity, Authz), CommandError> {
let mut ssh_agent = if let Some(ssh_auth_sock) = &ctx.get_globals().ssh_auth_sock {
Expand All @@ -915,13 +914,10 @@ async fn authenticate<E>(
};
let verified = signed.verify_with_ssh_public_key(&public_key)?;
let credentials = Credentials::new(verified);
let mut request = client
let request = client
.iam()
.authorization(credentials.to_string())
.body(public_key.to_string());
if let Some(via) = via {
request = request.via(via.to_string());
}
let identity = match request.send().await {
Ok(identity) => identity.into_inner(),
Err(error) if error.status() == Some(StatusCode::UNAUTHORIZED) => {
Expand All @@ -933,6 +929,24 @@ async fn authenticate<E>(
Ok((identity, Authz::new(credentials, key)))
}

/// Record the agent's key ID, so that requests made before logging
/// in claim it. A server with no proxy in front needs nothing more.
/// If the agent has no key to offer, record nothing, and let the
/// login report why.
async fn claim_key(ctx: &impl CommandContext, signer: &AuthzSigner) {
if signer.get().is_some() || signer.claim().is_some() {
return;
}
let globals = ctx.get_globals();
if let Some(ssh_auth_sock) = &globals.ssh_auth_sock
&& let Ok(mut ssh_agent) = SshAgentConnection::connect(ssh_auth_sock).await
&& let Ok(public_key) = ssh_agent.identity(globals.ssh_key_id.as_ref()).await
&& let Ok(key_id) = public_key.key_id()
{
signer.set_claim(key_id);
}
}

/// A refused login usually indicates build skew rather than a bad key.
/// Blame the builds when they differ, otherwise return the server error.
fn login_refused(server: Option<VersionInfo>, error: CommandError) -> CommandError {
Expand All @@ -950,29 +964,14 @@ fn login_refused(server: Option<VersionInfo>, error: CommandError) -> CommandErr
async fn with_login<T, E, Req>(
ctx: &mut impl CommandContext,
client: &Client,
make_request: Req,
) -> Result<T, CommandError>
where
Req: AsyncFnMut() -> Result<T, ClientError<E>>,
CommandError: From<ClientError<E>>,
{
with_login_via(ctx, client, None, make_request).await
}

/// Like [`with_login`], for a request that the proxy routes to sled
/// `via`. A sled that refuses a request answers with a login challenge
/// that only it can verify, so the login goes to `via` as well.
async fn with_login_via<T, E, Req>(
ctx: &mut impl CommandContext,
client: &Client,
via: Option<&BaseboardId>,
mut make_request: Req,
) -> Result<T, CommandError>
where
Req: AsyncFnMut() -> Result<T, ClientError<E>>,
CommandError: From<ClientError<E>>,
{
let signer = ctx.authz_signer();
claim_key(ctx, &signer).await;
let mut generation = signer.generation();
let mut logged_in = false;
loop {
Expand All @@ -981,7 +980,7 @@ where
if err.status() == StatusCode::UNAUTHORIZED && !logged_in =>
{
if let Some(_login) = signer.login_needed(generation).await {
let (_identity, authz) = authenticate(ctx, client, via, err).await?;
let (_identity, authz) = authenticate(ctx, client, err).await?;
ctx.set_credentials(Some(authz));
logged_in = true;
}
Expand Down Expand Up @@ -1534,7 +1533,7 @@ async fn job_start(
max_fsize,
} = limits.as_limits();
let mut start_ctx = ctx.clone();
let start = with_login_via(&mut start_ctx, client, Some(&target), async || {
let start = with_login(&mut start_ctx, client, async || {
let mut start = client
.job_start()
.job_id(job.job_id())
Expand Down Expand Up @@ -1749,7 +1748,7 @@ async fn job_start(
let multiple = status.len() > 1;
for baseboard in status.keys() {
for stream in [Stdout, Stderr] {
match with_login_via(ctx, client, Some(baseboard), async || {
match with_login(ctx, client, async || {
client
.job_output()
.job_id(job_id)
Expand Down Expand Up @@ -1932,7 +1931,7 @@ async fn job_stop(
job_id: &JobId,
via: Option<&BaseboardId>,
) -> Result<(), CommandError> {
with_login_via(ctx, client, via, async || {
with_login(ctx, client, async || {
let mut request = client.job_stop().job_id(job_id).wait(JobWait::Stop);
if let Some(via) = via {
request = request.via(via.to_string());
Expand Down Expand Up @@ -1963,7 +1962,7 @@ async fn job_status_map(
job_id: &JobId,
via: Option<&BaseboardId>,
) -> Result<JobStatusMap, CommandError> {
let status = with_login_via(ctx, client, via, async || {
let status = with_login(ctx, client, async || {
let mut request = client.job_status().job_id(job_id);
if let Some(via) = via {
request = request.via(via.to_string());
Expand Down Expand Up @@ -2273,7 +2272,7 @@ async fn job_output_from(
} else {
// Download and print the output all at once. If hash verification
// fails here, do not print any output.
let byte_stream = with_login_via(ctx, client, Some(target), {
let byte_stream = with_login(ctx, client, {
async || {
client
.job_output()
Expand Down Expand Up @@ -2387,7 +2386,7 @@ async fn job_attach(
job_id: &JobId,
target: &BaseboardId,
) -> Result<(), CommandError> {
match with_login_via(ctx, client, Some(target), async || {
match with_login(ctx, client, async || {
client
.job_attach()
.job_id(job_id)
Expand Down Expand Up @@ -2422,7 +2421,7 @@ async fn job_stream(
mut file: File,
) -> Result<Hasher, CommandError> {
let io_error = |error| CommandError::io(path, error);
let socket = with_login_via(ctx, client, Some(target), async || {
let socket = with_login(ctx, client, async || {
client
.job_attach()
.job_id(job_id)
Expand Down
5 changes: 3 additions & 2 deletions client/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ impl Authz {
}
}

/// The `Authorization` header binding one request.
pub fn header(&self, method: &str, target: &str) -> String {
/// Sign one request with the login's ephemeral key, yielding its
/// `Authorization` header.
pub fn sign(&self, method: &str, target: &str) -> String {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
let request = BoundRequest::new(method, target, seq);
self.key
Expand Down
44 changes: 34 additions & 10 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use std::process::exit;
use std::sync::{Arc, Mutex};

use reqwest::header::AUTHORIZATION;
use sush_common::authn::AuthnError;
use sush_common::authn::{AuthnError, BoundCredentials};
use sush_common::keys::KeyId;
use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};

use crate::context::Authz;
Expand Down Expand Up @@ -74,6 +75,9 @@ struct AuthzState {
/// The current authorization, and how many times it has been set.
authz: Mutex<(Option<Authz>, u64)>,

/// The key ID to claim in requests made before logging in.
claim: Mutex<Option<KeyId>>,

/// Held during a login. When the server refuses several concurrent
/// requests, the first to take this lock logs in, and the others
/// retry with its credentials.
Expand All @@ -91,6 +95,18 @@ impl AuthzSigner {
guard.1 += 1;
}

pub fn claim(&self) -> Option<KeyId> {
self.0.claim.lock().unwrap().clone()
}

pub fn set_claim(&self, key_id: KeyId) {
*self.0.claim.lock().unwrap() = Some(key_id);
}

pub fn clear_claim(&self) {
*self.0.claim.lock().unwrap() = None;
}

/// How many times the authorization has been set.
pub fn generation(&self) -> u64 {
self.0.authz.lock().unwrap().1
Expand All @@ -105,29 +121,37 @@ impl AuthzSigner {
}
}

/// Sign `request` with the current ephemeral key, unless it already
/// carries an `Authorization` header (initial authentication does).
/// Sign `request` with the login's ephemeral key. Before logging in,
/// only claim the key that will log in.
async fn sign_request(
signer: &AuthzSigner,
request: &mut reqwest::Request,
) -> Result<(), AuthnError> {
// A login request carries its own credentials.
if request.headers().contains_key(AUTHORIZATION) {
return Ok(());
}
let Some(authz) = signer.get() else {
let header = if let Some(authz) = signer.get() {
authz.sign(request.method().as_str(), &request_target(request.url()))
} else if let Some(key_id) = signer.claim() {
BoundCredentials::claim(key_id).to_string()
} else {
return Ok(());
};
let url = request.url();
let target = match url.query() {
Some(query) => format!("{}?{query}", url.path()),
None => url.path().to_string(),
};
let header = authz.header(request.method().as_str(), &target);
let header = header.parse().map_err(|_| AuthnError::InvalidParam)?;
request.headers_mut().insert(AUTHORIZATION, header);
Ok(())
}

/// Format the path and query of `url` exactly as sent. The proxy
/// verifies the request signature against these bytes.
fn request_target(url: &reqwest::Url) -> String {
match url.query() {
Some(query) => format!("{}?{query}", url.path()),
None => url.path().to_string(),
}
}

progenitor::generate_api!(
spec = "../sush.json", // must match `sush_common::OPENAPI_DOCUMENT`
interface = Builder,
Expand Down
1 change: 1 addition & 0 deletions client/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ impl CommandContext for Repl {
}
if ssh_auth_sock.is_some() || ssh_key_id.is_some() {
self.set_credentials(None);
self.authz_signer().clear_claim();
}

macro_rules! set_or_unset {
Expand Down
Loading