Skip to content
Draft
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
6 changes: 3 additions & 3 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ memmap2 = "0.9"
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
pem-rfc7468 = { version = "0.7", features = ["std"] }
percent-encoding = "2"
permission-slip-client = { git = "https://github.com/oxidecomputer/permission-slip", rev = "2a8adcadde01518f1d39993a1cc45ec3b78b3204" }
permission-slip-common = { git = "https://github.com/oxidecomputer/permission-slip", rev = "2a8adcadde01518f1d39993a1cc45ec3b78b3204" }
permission-slip-client = { git = "https://github.com/oxidecomputer/permission-slip", rev = "c9d302ef44576e075e35f4f28f8e7ac8345bcd4b" }
permission-slip-common = { git = "https://github.com/oxidecomputer/permission-slip", rev = "c9d302ef44576e075e35f4f28f8e7ac8345bcd4b" }
progenitor = "0.14"
progenitor-client = "0.14"
pwd = "1"
Expand Down
99 changes: 80 additions & 19 deletions client/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, Permissions};
use std::fs;
use std::io::{self, BufRead as _, ErrorKind, Read as _, Write as _, stderr, stdin, stdout};
use std::os::unix::fs::PermissionsExt as _;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
Expand All @@ -17,7 +17,7 @@ use anstream::print;
use anstyle::{AnsiColor, Style};
use atomicwrites::{AtomicFile, OverwriteBehavior};
use bytesize::ByteSize;
use chrono::TimeDelta;
use chrono::{DateTime, TimeDelta, Utc};
use humantime::format_duration;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rustix::io::ioctl_fionread;
Expand All @@ -43,8 +43,15 @@ use crate::context::{CommandContext, OutputFormat, StatusDisplayStyle};
use crate::types::SessionStartNonce;

pub(crate) const PREFIX: &str = "sush";
const SESSION_FILE: &str = "session.json";
const SESSION_FILE_NAME: &str = "session.json";
const SESSION_FILE_VERSION: u32 = 1;
const TOKEN_FILE_NAME: &str = "permslip-token.json";
const TOKEN_FILE_VERSION: u32 = 1;

/// How long a permslip token is trusted for reuse. Staying well
/// under the server's 15 minute TTL spares a command from expiring
/// mid-flight.
const TOKEN_REUSE: TimeDelta = TimeDelta::minutes(10);

/// The persisted session, versioned for future migrations.
#[derive(Deserialize, Serialize)]
Expand All @@ -53,6 +60,17 @@ struct SavedSession {
session: Session,
}

/// A persisted permslip token, versioned for future migrations. The
/// url and fingerprint pin it to one signing server and one identity.
#[derive(Deserialize, Serialize)]
struct SavedToken {
version: u32,
url: String,
fingerprint: String,
token: String,
created: DateTime<Utc>,
}

#[derive(Clone, Debug, Default)]
pub struct Cli {
globals: Arc<Mutex<GlobalArgs>>,
Expand All @@ -61,6 +79,7 @@ pub struct Cli {
watch: Arc<Mutex<Option<Watch>>>,
session: Arc<Mutex<Option<Session>>>,
session_file: Option<PathBuf>,
token_file: Option<PathBuf>,
credentials: AuthzSigner,
}

Expand All @@ -69,7 +88,7 @@ impl Cli {
/// Without persistence, every one-shot command would need a fresh
/// `session attach`.
pub fn load_session(&mut self) {
let path = match BaseDirectories::with_prefix(PREFIX).place_state_file(SESSION_FILE) {
let path = match BaseDirectories::with_prefix(PREFIX).place_state_file(SESSION_FILE_NAME) {
Ok(path) => path,
Err(error) => {
eprintln!("⚠️ The session will not persist: {error}");
Expand All @@ -91,6 +110,10 @@ impl Cli {
Err(error) => eprintln!("⚠️ Ignoring the saved session: {error}"),
}
self.session_file = Some(path);
match BaseDirectories::with_prefix(PREFIX).place_state_file(TOKEN_FILE_NAME) {
Ok(path) => self.token_file = Some(path),
Err(error) => eprintln!("⚠️ Signing tokens will not persist: {error}"),
}
}

/// Adopt `session` unless one with the same ID is already
Expand Down Expand Up @@ -119,18 +142,7 @@ impl Cli {
session: session.clone(),
})
.map_err(io::Error::other)
.and_then(|json| {
AtomicFile::new(path, OverwriteBehavior::AllowOverwrite)
.write(|file| {
file.set_permissions(Permissions::from_mode(0o600))?;
file.write_all(&json)
})
.map_err(|error| match error {
atomicwrites::Error::Internal(error) | atomicwrites::Error::User(error) => {
error
}
})
}),
.and_then(|json| write_private(path, &json)),
None => match fs::remove_file(path) {
Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
_ => Ok(()),
Expand All @@ -142,6 +154,18 @@ impl Cli {
}
}

/// Atomically write a file only the user may read, born that way
/// rather than chmodded after opening.
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true).mode(0o600);
AtomicFile::new(path, OverwriteBehavior::AllowOverwrite)
.write_with_options(|file| file.write_all(bytes), options)
.map_err(|error| match error {
atomicwrites::Error::Internal(error) | atomicwrites::Error::User(error) => error,
})
}

fn byte_size(len: u64) -> bytesize::Display {
ByteSize::b(len).display().si()
}
Expand Down Expand Up @@ -247,6 +271,43 @@ impl CommandContext for Cli {
self.credentials.clone()
}

fn permslip_token(&self, url: &str, fingerprint: &str) -> Option<String> {
let json = fs::read(self.token_file.as_ref()?).ok()?;
match serde_json::from_slice::<SavedToken>(&json) {
Ok(SavedToken {
version: TOKEN_FILE_VERSION,
url: saved_url,
fingerprint: saved_fingerprint,
token,
created,
}) if saved_url == url
&& saved_fingerprint == fingerprint
&& (TimeDelta::zero()..TOKEN_REUSE).contains(&(Utc::now() - created)) =>
{
Some(token)
}
_ => None,
}
}

fn save_permslip_token(&self, url: &str, fingerprint: &str, token: &str) {
let Some(path) = &self.token_file else {
return;
};
let result = serde_json::to_vec_pretty(&SavedToken {
version: TOKEN_FILE_VERSION,
url: url.to_owned(),
fingerprint: fingerprint.to_owned(),
token: token.to_owned(),
created: Utc::now(),
})
.map_err(io::Error::other)
.and_then(|json| write_private(path, &json));
if let Err(error) = result {
eprintln!("⚠️ The token was not saved: {error}");
}
}

fn session_id(&self) -> Option<SessionId> {
self.session
.lock()
Expand Down Expand Up @@ -866,7 +927,7 @@ impl CommandContext for Cli {
time_authenticated,
time_revoked,
} = identity;
let fingerprint = public_key.fingerprint(Default::default()).to_string();
let fingerprint = public_key.fingerprint();
let algorithm = public_key.algorithm();
let comment = public_key.comment();
println!(
Expand Down Expand Up @@ -895,7 +956,7 @@ impl CommandContext for Cli {
} else {
for key in keys {
let key_id = key.key_id()?;
let fingerprint = key.fingerprint(Default::default()).to_string();
let fingerprint = key.fingerprint();
let algorithm = key.algorithm();
let comment = key.comment();
match self.get_output_format() {
Expand Down
72 changes: 52 additions & 20 deletions client/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ use crate::context::{Authz, CommandContext, OutputFormat, StatusDisplayStyle};
use crate::identity::{IdentityError, SshAgentConnection};
use crate::interactive::interactive_job;
#[cfg(feature = "permslip")]
use crate::permslip::{PermslipError, PermslipSigner};
use crate::permslip::{PermslipError, PermslipSigner, fresh_token};
use crate::repl::Repl;
use crate::tls;
use crate::tunnel::{Tunnel, TunnelError};
Expand Down Expand Up @@ -1153,22 +1153,23 @@ async fn session(
) => {
// Creation already announces the session; don't echo it
// when the start succeeds.
let (session_id, nonce, show) =
if let (Some(session_id), Some(nonce)) = (session_id, nonce) {
(session_id, nonce, true)
} else {
let (baseboard_id, nonce) = with_login(ctx, client, async || {
Ok((
client.target().send().await?.into_inner(),
client.session_start_nonce().send().await?.into_inner(),
))
})
.await?;
let (session_id, nonce) =
session_create(permslip, permslip_url, &baseboard_id, nonce.nonce).await?;
ctx.session_created(Session::new(session_id), nonce);
(session_id, nonce, false)
};
let (session_id, nonce, show) = if let (Some(session_id), Some(nonce)) =
(session_id, nonce)
{
(session_id, nonce, true)
} else {
let (baseboard_id, nonce) = with_login(ctx, client, async || {
Ok((
client.target().send().await?.into_inner(),
client.session_start_nonce().send().await?.into_inner(),
))
})
.await?;
let (session_id, nonce) =
session_create(ctx, permslip, permslip_url, &baseboard_id, nonce.nonce).await?;
ctx.session_created(Session::new(session_id), nonce);
(session_id, nonce, false)
};
session_start(ctx, client, session_id, nonce, wait, show).await
}

Expand All @@ -1183,7 +1184,7 @@ async fn session(
_,
) => {
let (session_id, nonce) =
session_create(permslip, permslip_url, &baseboard_id, nonce).await?;
session_create(ctx, permslip, permslip_url, &baseboard_id, nonce).await?;
ctx.session_created(Session::new(session_id), nonce);
Ok(())
}
Expand Down Expand Up @@ -1357,7 +1358,7 @@ async fn job(
} else {
JobMode::Batch
};
let signer = PermslipSigner::new(key_name, permslip_url).await?;
let signer = permslip_signer(ctx, key_name, permslip_url).await?;
let mut interval = interval(SIGNING_UPDATE_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let sign = signer.sign_job_request(JobStartRequest::new(
Expand Down Expand Up @@ -1732,9 +1733,40 @@ fn parse_baseboard_id(value: &str) -> Result<BaseboardId, String> {
value.parse::<BaseboardId>().map_err(|e| e.to_string())
}

/// A permslip signer authenticated as the agent key `-s`/`SUSH_KEY_ID`
/// names, or the agent's first. We choose the key rather than letting
/// permslip choose, so that one identity serves both the rack and the
/// signing service.
#[cfg(feature = "permslip")]
async fn permslip_signer(
ctx: &mut impl CommandContext,
key_name: &str,
permslip_url: &str,
) -> Result<PermslipSigner, CommandError> {
let globals = ctx.get_globals();
let Some(sock) = globals.ssh_auth_sock.clone() else {
return Err(CommandError::MissingSshAuthSock);
};
let key_id = globals.ssh_key_id.clone();
let mut agent = SshAgentConnection::connect(&sock).await?;
let key = agent.identity(key_id.as_ref()).await?;
let fingerprint = key.fingerprint();
let token = match ctx.permslip_token(permslip_url, &fingerprint) {
Some(token) => token,
None => {
ctx.please_touch(&key)?;
let token = fresh_token(permslip_url, sock, fingerprint.clone()).await?;
ctx.save_permslip_token(permslip_url, &fingerprint, &token);
token
}
};
Ok(PermslipSigner::new(key_name, permslip_url, &token)?)
}

/// Ask the online signing service to create a session.
#[cfg(feature = "permslip")]
async fn session_create(
ctx: &mut impl CommandContext,
permslip: Option<String>,
permslip_url: Option<String>,
baseboard_id: &BaseboardId,
Expand All @@ -1747,7 +1779,7 @@ async fn session_create(
return Err(CommandError::MissingKeyName);
};
let invalid = |e: InvalidCodephrase| CommandError::UnsupportedPermslipResponse(e.to_string());
let signer = PermslipSigner::new(permslip_key, &permslip_url).await?;
let signer = permslip_signer(ctx, &permslip_key, &permslip_url).await?;
let created = signer.create_session(baseboard_id, nonce).await?;
Ok((
created.session_id.to_string().parse().map_err(invalid)?,
Expand Down
7 changes: 7 additions & 0 deletions client/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ pub trait CommandContext: Clone + Send + Sync {
fn set_credentials(&mut self, credentials: Option<Authz>) {
self.authz_signer().set(credentials)
}
/// The cached permslip token for `url` and the key `fingerprint`
/// names, if it is still fresh. The default caches nothing.
fn permslip_token(&self, _url: &str, _fingerprint: &str) -> Option<String> {
None
}
/// Remember a permslip token for reuse.
fn save_permslip_token(&self, _url: &str, _fingerprint: &str, _token: &str) {}
fn session_id(&self) -> Option<SessionId>;
fn next_job_id(&self) -> Result<JobId, CommandError>;
fn session_start_params(&self, baseboard_id: BaseboardId, nonce: SessionStartNonce);
Expand Down
Loading