diff --git a/Cargo.lock b/Cargo.lock index 078598d..115e55a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3122,7 +3122,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "permission-slip-client" version = "1.1.0" -source = "git+https://github.com/oxidecomputer/permission-slip?rev=2a8adcadde01518f1d39993a1cc45ec3b78b3204#2a8adcadde01518f1d39993a1cc45ec3b78b3204" +source = "git+https://github.com/oxidecomputer/permission-slip?rev=c9d302ef44576e075e35f4f28f8e7ac8345bcd4b#c9d302ef44576e075e35f4f28f8e7ac8345bcd4b" dependencies = [ "anyhow", "base64 0.22.1", @@ -3158,7 +3158,7 @@ dependencies = [ [[package]] name = "permission-slip-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/permission-slip?rev=2a8adcadde01518f1d39993a1cc45ec3b78b3204#2a8adcadde01518f1d39993a1cc45ec3b78b3204" +source = "git+https://github.com/oxidecomputer/permission-slip?rev=c9d302ef44576e075e35f4f28f8e7ac8345bcd4b#c9d302ef44576e075e35f4f28f8e7ac8345bcd4b" dependencies = [ "aws-types", "blake3", @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "sshauth" version = "0.1.2" -source = "git+https://github.com/oxidecomputer/sshauth#6233c1b25d9e3b03d51ab95b19ed7903bfadfd8f" +source = "git+https://github.com/oxidecomputer/sshauth?rev=afd9b4e549d0e9229c2910c94aa7bca76623dfdb#afd9b4e549d0e9229c2910c94aa7bca76623dfdb" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 37766b1..07505a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/client/src/cli.rs b/client/src/cli.rs index 158b370..6f86bd4 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -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}; @@ -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; @@ -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)] @@ -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, +} + #[derive(Clone, Debug, Default)] pub struct Cli { globals: Arc>, @@ -61,6 +79,7 @@ pub struct Cli { watch: Arc>>, session: Arc>>, session_file: Option, + token_file: Option, credentials: AuthzSigner, } @@ -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}"); @@ -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 @@ -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(()), @@ -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() } @@ -247,6 +271,43 @@ impl CommandContext for Cli { self.credentials.clone() } + fn permslip_token(&self, url: &str, fingerprint: &str) -> Option { + let json = fs::read(self.token_file.as_ref()?).ok()?; + match serde_json::from_slice::(&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 { self.session .lock() @@ -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!( @@ -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() { diff --git a/client/src/commands.rs b/client/src/commands.rs index 4af8363..b7df19b 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -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}; @@ -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 } @@ -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(()) } @@ -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( @@ -1732,9 +1733,40 @@ fn parse_baseboard_id(value: &str) -> Result { value.parse::().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 { + 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, permslip_url: Option, baseboard_id: &BaseboardId, @@ -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)?, diff --git a/client/src/context.rs b/client/src/context.rs index adaf193..a3687bf 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -114,6 +114,13 @@ pub trait CommandContext: Clone + Send + Sync { fn set_credentials(&mut self, credentials: Option) { 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 { + None + } + /// Remember a permslip token for reuse. + fn save_permslip_token(&self, _url: &str, _fingerprint: &str, _token: &str) {} fn session_id(&self) -> Option; fn next_job_id(&self) -> Result; fn session_start_params(&self, baseboard_id: BaseboardId, nonce: SessionStartNonce); diff --git a/client/src/permslip.rs b/client/src/permslip.rs index b3891f6..dd7024b 100644 --- a/client/src/permslip.rs +++ b/client/src/permslip.rs @@ -4,7 +4,8 @@ //! Use Permission Slip to sign Support Shell job requests. -use permslip_client_lib::login::{IdentityProvider, TokenProvider}; +use http::HeaderValue; +use permslip_client_lib::login::TokenProvider; use permslip_client_lib::types::{CreateSushSessionBody, CreatedSushSession, Error as ApiError}; use permslip_client_lib::{Client, ClientRequestBuilder, Error as ClientError}; use sled_hardware_types::BaseboardId; @@ -20,12 +21,32 @@ pub struct PermslipSigner { key_name: String, } +/// Get a bearer token by the sshauth challenge, signed with the agent +/// key named by `fingerprint`. +pub async fn fresh_token( + url: &str, + agent_sock: String, + fingerprint: String, +) -> Result { + let tokens = TokenProvider::SshAuth { + fingerprint: Some(fingerprint), + server_url: url.to_owned(), + agent_sock, + }; + let token = tokens.token().await.map_err(PermslipError::token)?; + let value = token.into_header_value().map_err(PermslipError::token)?; + value + .to_str() + .map(str::to_owned) + .map_err(PermslipError::token) +} + impl PermslipSigner { - pub async fn new>(key_name: N, url: &str) -> Result { - let tokens = TokenProvider::IdP(IdentityProvider::Google); - let mut builder = ClientRequestBuilder::new(); - let token = tokens.token().await.map_err(PermslipError::token)?; - builder = builder.token(token.into_header_value().map_err(PermslipError::token)?); + /// A signer authenticated with `token`. + pub fn new>(key_name: N, url: &str, token: &str) -> Result { + let mut token = HeaderValue::from_str(token).map_err(PermslipError::token)?; + token.set_sensitive(true); + let builder = ClientRequestBuilder::new().token(token); Ok(Self { client: Client::new_with_client( url, diff --git a/client/src/repl.rs b/client/src/repl.rs index 578a562..57d91a0 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -228,6 +228,14 @@ impl CommandContext for Repl { self.cli.authz_signer() } + fn permslip_token(&self, url: &str, fingerprint: &str) -> Option { + self.cli.permslip_token(url, fingerprint) + } + + fn save_permslip_token(&self, url: &str, fingerprint: &str, token: &str) { + self.cli.save_permslip_token(url, fingerprint, token) + } + fn session_id(&self) -> Option { self.cli.session_id() } diff --git a/common/src/keys.rs b/common/src/keys.rs index 84fb1f6..6d62799 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -29,7 +29,8 @@ use schemars::{JsonSchema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use signature::Verifier; use ssh_key::{ - Algorithm as SshAlgorithm, EcdsaCurve, Error as SshKeyError, Mpint, Signature as SshSignature, + Algorithm as SshAlgorithm, EcdsaCurve, Error as SshKeyError, HashAlg, Mpint, + Signature as SshSignature, }; use thiserror::Error; use x509_cert::der::Encode as _; @@ -130,6 +131,11 @@ impl SshPublicKey { matches!(self.algorithm(), SkEcdsaSha2NistP256 | SkEd25519) } + /// The OpenSSH `SHA256:...` fingerprint. + pub fn fingerprint(&self) -> String { + self.0.fingerprint(HashAlg::Sha256).to_string() + } + pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<(), KeyError> { signature.verify_with_ssh_public_key(message, self) }