diff --git a/Cargo.lock b/Cargo.lock index 1568fa84..86dad8b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4589,7 +4589,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" [[package]] name = "sprockets-tls" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/sprockets.git?rev=98615b86d51f74ce220198eb16c57932fd24e7cb#98615b86d51f74ce220198eb16c57932fd24e7cb" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=02c7e5414d97c0c85d629cb76d1f6beab73d960e#02c7e5414d97c0c85d629cb76d1f6beab73d960e" dependencies = [ "anyhow", "attest-data", @@ -4621,7 +4621,7 @@ dependencies = [ [[package]] name = "sprockets-tls-test-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/sprockets.git?rev=98615b86d51f74ce220198eb16c57932fd24e7cb#98615b86d51f74ce220198eb16c57932fd24e7cb" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=02c7e5414d97c0c85d629cb76d1f6beab73d960e#02c7e5414d97c0c85d629cb76d1f6beab73d960e" dependencies = [ "camino", "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04)", @@ -4916,6 +4916,32 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "sush-proxy" +version = "0.1.0" +dependencies = [ + "camino", + "chrono", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-util", + "lru", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sled-hardware-types", + "slog", + "sush-api", + "sush-common", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "x509-cert", +] + [[package]] name = "sush-server" version = "0.1.0" @@ -4937,18 +4963,15 @@ dependencies = [ "http-body-util", "http-range-header", "hyper", - "hyper-util", "libc", "libcontract-sys", "lru", "memmap2", "p256", - "percent-encoding", "pwd", "rand_core 0.6.4", "rumors", "rustix 1.1.4", - "rustls", "schemars 0.8.22", "serde", "serde_json", @@ -4964,7 +4987,6 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", - "tokio-rustls", "tokio-stream", "tokio-tungstenite", "tokio-util", @@ -4987,6 +5009,7 @@ dependencies = [ "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04)", "pwd", "rand_core 0.6.4", + "reqwest", "rumors", "serde_json", "sha3 0.12.0", @@ -4998,6 +5021,7 @@ dependencies = [ "sush-api", "sush-client", "sush-common", + "sush-proxy", "sush-server", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 95187ad7..95c0816e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "client", "common", "server", "tests", "xtask"] +members = ["api", "client", "common", "proxy", "server", "tests", "xtask"] resolver = "2" [workspace.package] @@ -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" diff --git a/client/src/commands.rs b/client/src/commands.rs index ed3837f2..92d341a7 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -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). @@ -888,7 +888,6 @@ impl ClientCommand { async fn authenticate( ctx: &mut impl CommandContext, client: &Client, - via: Option<&BaseboardId>, response: ResponseValue, ) -> Result<(Identity, Authz), CommandError> { let mut ssh_agent = if let Some(ssh_auth_sock) = &ctx.get_globals().ssh_auth_sock { @@ -915,13 +914,10 @@ async fn authenticate( }; 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) => { @@ -933,6 +929,24 @@ async fn authenticate( 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, error: CommandError) -> CommandError { @@ -950,22 +964,6 @@ fn login_refused(server: Option, error: CommandError) -> CommandErr async fn with_login( ctx: &mut impl CommandContext, client: &Client, - make_request: Req, -) -> Result -where - Req: AsyncFnMut() -> Result>, - CommandError: From>, -{ - 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( - ctx: &mut impl CommandContext, - client: &Client, - via: Option<&BaseboardId>, mut make_request: Req, ) -> Result where @@ -973,6 +971,7 @@ where CommandError: From>, { let signer = ctx.authz_signer(); + claim_key(ctx, &signer).await; let mut generation = signer.generation(); let mut logged_in = false; loop { @@ -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; } @@ -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()) @@ -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) @@ -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()); @@ -1963,7 +1962,7 @@ async fn job_status_map( job_id: &JobId, via: Option<&BaseboardId>, ) -> Result { - 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()); @@ -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() @@ -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) @@ -2422,7 +2421,7 @@ async fn job_stream( mut file: File, ) -> Result { 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) diff --git a/client/src/context.rs b/client/src/context.rs index 805677fc..b958841a 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -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 diff --git a/client/src/lib.rs b/client/src/lib.rs index b3b62085..298f4a2d 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -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; @@ -74,6 +75,9 @@ struct AuthzState { /// The current authorization, and how many times it has been set. authz: Mutex<(Option, u64)>, + /// The key ID to claim in requests made before logging in. + claim: Mutex>, + /// 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. @@ -91,6 +95,18 @@ impl AuthzSigner { guard.1 += 1; } + pub fn claim(&self) -> Option { + 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 @@ -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, diff --git a/client/src/repl.rs b/client/src/repl.rs index 32226c54..628bbf31 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -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 { diff --git a/common/src/authn.rs b/common/src/authn.rs index bd593289..3dc26bd3 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -138,14 +138,16 @@ impl RequestKey { let signature = self.0.sign(&request.to_be_signed(&key_id, &nonce)); BoundCredentials { key_id, - nonce, - seq: request.seq, - signature: EncodedSignature { - r: EccR::from_be_bytes(*signature.r_bytes()), - s: EccS::from_be_bytes(*signature.s_bytes()), - flags: 0, - counter: 0, - }, + signature: Some(RequestSignature { + nonce, + seq: request.seq, + signature: EncodedSignature { + r: EccR::from_be_bytes(*signature.r_bytes()), + s: EccS::from_be_bytes(*signature.s_bytes()), + flags: 0, + counter: 0, + }, + }), } } } @@ -157,16 +159,17 @@ codephrase_newtype! { } impl RequestVerifier { - /// Verify that `credentials` sign `request`. + /// Verify that `proof` signs `request` for `key_id`. pub fn verify( &self, request: &BoundRequest, - credentials: &BoundCredentials, + key_id: &KeyId, + proof: &RequestSignature, ) -> Result<(), AuthnError> { - if request.seq != credentials.seq { + if request.seq != proof.seq { return Err(AuthnError::InvalidSignature); } - let EncodedSignature { r, s, .. } = &credentials.signature; + let EncodedSignature { r, s, .. } = &proof.signature; let signature = Ed25519Signature::from_components(r.to_be_bytes(), s.to_be_bytes()); let key = @@ -177,11 +180,8 @@ impl RequestVerifier { return Err(AuthnError::InvalidKey); } - key.verify_strict( - &request.to_be_signed(&credentials.key_id, &credentials.nonce), - &signature, - ) - .map_err(|_| AuthnError::InvalidSignature) + key.verify_strict(&request.to_be_signed(key_id, &proof.nonce), &signature) + .map_err(|_| AuthnError::InvalidSignature) } } @@ -227,12 +227,21 @@ impl BoundRequest { } } -/// Credentials binding one request to an authenticated identity. +/// Credentials for one request. The key ID claims the requester's +/// identity. The signature proves the claim, but is absent until the +/// client logs in. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BoundCredentials { - /// Key ID of the authenticated identity. + /// Key ID of the requester. pub key_id: KeyId, - /// The server-chosen nonce from authentication. + /// Proof that the requester logged in and sent this request. + pub signature: Option, +} + +/// An ephemeral signature binding one request to a login. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestSignature { + /// The server-chosen nonce from the login. pub nonce: Nonce, /// The request's sequence number. pub seq: u64, @@ -240,18 +249,29 @@ pub struct BoundCredentials { pub signature: EncodedSignature, } +impl BoundCredentials { + /// Claim `key_id` as the requester's identity, without proof. + pub fn claim(key_id: KeyId) -> Self { + Self { + key_id, + signature: None, + } + } +} + impl fmt::Display for BoundCredentials { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self { - key_id, + let Self { key_id, signature } = self; + write!(f, "{AUTHN_SCHEME} key-id={key_id}")?; + if let Some(RequestSignature { nonce, seq, signature: EncodedSignature { r, s, .. }, - } = self; - write!( - f, - "{AUTHN_SCHEME} key-id={key_id}, nonce={nonce}, seq={seq}, r={r}, s={s}", - ) + }) = signature + { + write!(f, ", nonce={nonce}, seq={seq}, r={r}, s={s}")?; + } + Ok(()) } } @@ -260,25 +280,28 @@ impl FromStr for BoundCredentials { fn from_str(s: &str) -> Result { let mut params = AuthnParams::from_str(s)?; - let creds = Self { - key_id: params.parse("key-id")?, - nonce: params.parse("nonce")?, - seq: params.parse("seq")?, - signature: EncodedSignature { - r: params.parse("r")?, - - s: params.parse("s")?, - flags: 0, - counter: 0, - }, + let key_id = params.parse("key-id")?; + let signature = if ["nonce", "seq", "r", "s"].iter().any(|p| params.has(p)) { + Some(RequestSignature { + nonce: params.parse("nonce")?, + seq: params.parse("seq")?, + signature: EncodedSignature { + r: params.parse("r")?, + s: params.parse("s")?, + flags: 0, + counter: 0, + }, + }) + } else { + None }; params.finished()?; - Ok(creds) + Ok(Self { key_id, signature }) } } /// An `Authorization` header: initial credentials authenticate at -/// `iam`, bound credentials authorize every other request. +/// `iam`, bound credentials identify the sender of every other request. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Authn { Initial(Box), @@ -289,10 +312,20 @@ impl FromStr for Authn { type Err = AuthnError; fn from_str(s: &str) -> Result { - if AuthnParams::from_str(s)?.has("seq") { - Ok(Self::Bound(s.parse()?)) - } else { + if AuthnParams::from_str(s)?.has("cnonce") { Ok(Self::Initial(Box::new(s.parse()?))) + } else { + Ok(Self::Bound(s.parse()?)) + } + } +} + +impl Authn { + /// The key ID of the requester. + pub fn key_id(&self) -> &KeyId { + match self { + Self::Initial(credentials) => &credentials.key_id, + Self::Bound(credentials) => &credentials.key_id, } } } @@ -352,9 +385,6 @@ pub struct ChallengeResponse { epk: RequestVerifier, } -/// Signed authentication evidence, self-certifying to any verifier. -pub type SignedLogin = Signed; - impl ChallengeResponse { const TYPE_NAME: &[u8] = b"sush_common::authn::ChallengeResponse"; @@ -601,7 +631,10 @@ pub enum AuthnError { #[cfg(test)] mod test { + use std::env; + use std::fs::{read_to_string, write}; use std::time::Duration; + use x509_cert::name::Name; use x509_cert::time::Validity; @@ -609,20 +642,19 @@ mod test { use super::*; - /// A login is signed, gossiped, and re-verified by every sled, - /// which rebuilds it from the wire to check the signature. Two - /// shapes in one rack disagree about which logins verify. This - /// pin freezes the shape: any field change fails here. Do not - /// re-pin; add a new wire version. + /// The client signs a `ChallengeResponse` and the server rebuilds it + /// to verify the signature. The client and server must therefore + /// agree on the fields. To change them, add a new wire version; + /// do not re-pin. #[test] fn pin_challenge_response_schema() { let schema = serde_json::to_string_pretty(&schemars::schema_for!(ChallengeResponse)).unwrap(); let path = "tests/output/challenge-response-schema.json"; - if std::env::var("EXPECTORATE").as_deref() == Ok("overwrite") { - std::fs::write(path, &schema).unwrap(); + if env::var("EXPECTORATE").as_deref() == Ok("overwrite") { + write(path, &schema).unwrap(); } else { - let expected = std::fs::read_to_string(path).expect("missing snapshot"); + let expected = read_to_string(path).expect("missing snapshot"); assert_eq!(schema, expected, "the signed login's shape changed"); } } @@ -730,11 +762,26 @@ mod test { Authn::Bound(parsed) if parsed == creds )); + // Without a signature, the header is only a claim. + let claimed = BoundCredentials::claim(creds.key_id.clone()); + assert_eq!(claimed.to_string(), format!("Sush key-id={}", creds.key_id)); + assert!(matches!( + claimed.to_string().parse::().unwrap(), + Authn::Bound(parsed) if parsed == claimed + )); + assert!(matches!( + format!("{claimed}, seq=7").parse::(), + Err(AuthnError::MissingParam(_)) + )); + // The method is signed case-insensitively. Everything else // must match exactly. - verifier.verify(&request, &creds).unwrap(); - let verify = - |method, target, seq| verifier.verify(&BoundRequest::new(method, target, seq), &creds); + let key_id = &creds.key_id; + let proof = creds.signature.as_ref().unwrap(); + verifier.verify(&request, key_id, proof).unwrap(); + let verify = |method, target, seq| { + verifier.verify(&BoundRequest::new(method, target, seq), key_id, proof) + }; verify("GET", "/jobs?limit=1", 7).unwrap(); assert!(verify("POST", "/jobs?limit=1", 7).is_err()); assert!(verify("GET", "/jobs?limit=2", 7).is_err()); @@ -742,22 +789,19 @@ mod test { assert!( RequestKey::new() .verifier() - .verify(&request, &creds) + .verify(&request, key_id, proof) .is_err() ); // The identity is part of the signed material: credentials // re-labeled with another key ID or nonce do not verify. - let relabeled = BoundCredentials { - key_id: KeyId::from_str("ability").unwrap(), - ..creds.clone() - }; - assert!(verifier.verify(&request, &relabeled).is_err()); - let relabeled = BoundCredentials { + let other = KeyId::from_str("ability").unwrap(); + assert!(verifier.verify(&request, &other, proof).is_err()); + let relabeled = RequestSignature { nonce: Nonce::random(), - ..creds.clone() + ..proof.clone() }; - assert!(verifier.verify(&request, &relabeled).is_err()); + assert!(verifier.verify(&request, key_id, &relabeled).is_err()); // A small-order ephemeral key is refused at parse. let weak = RequestVerifier::from_be_bytes({ @@ -766,7 +810,7 @@ mod test { identity }); assert!(matches!( - weak.verify(&request, &creds).unwrap_err(), + weak.verify(&request, key_id, proof).unwrap_err(), AuthnError::InvalidKey )); } diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml new file mode 100644 index 00000000..3721c2bc --- /dev/null +++ b/proxy/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "sush-proxy" +description = "Oxide Support Shell proxy server" +version.workspace = true +edition = "2024" +license = "MPL-2.0" +repository = "https://github.com/oxidecomputer/sush" +publish = false + +[dependencies] +camino.workspace = true +chrono.workspace = true +http.workspace = true +http-body-util.workspace = true +hyper.workspace = true +hyper-util.workspace = true +lru.workspace = true +percent-encoding.workspace = true +rustls.workspace = true +serde.workspace = true +serde_json.workspace = true +sled-hardware-types.workspace = true +slog.workspace = true +sush-common = { path = "../common" } +thiserror.workspace = true +tokio.workspace = true +tokio-rustls.workspace = true +tokio-util.workspace = true + +[dev-dependencies] +sush-api = { path = "../api" } +x509-cert.workspace = true diff --git a/server/src/proxy.rs b/proxy/src/lib.rs similarity index 72% rename from server/src/proxy.rs rename to proxy/src/lib.rs index edf0f5b9..69e50c35 100644 --- a/server/src/proxy.rs +++ b/proxy/src/lib.rs @@ -4,13 +4,18 @@ //! Proxy server for the Oxide Support Shell API. //! -//! Terminates client connections and routes each request to a sled, -//! answering only `/version` itself. -//! A request that names a target goes to the first sled the target -//! resolves to. Anything else goes to the sled hosting the proxy, -//! which any request may reach. -//! Requests are forwarded untouched: bound request signatures cover -//! the exact request line, so the proxy may never rewrite one. +//! Terminates client connections, verifies request credentials, +//! and routes requests to servers. The proxy answers `/version` +//! and `/iam` itself; other requests are forwarded untouched. +//! +//! A request that specifies a target goes to the first sled the +//! target resolves to. Anything else goes to the server on the +//! sled hosting the proxy, the proxy's *home* sled. +//! +//! Clients authenticate the proxy by its RoT-vouched certificate; +//! see [`platform_tls`]. + +pub mod login; use std::collections::BTreeMap; use std::convert::Infallible; @@ -20,9 +25,9 @@ use std::sync::Arc; use std::time::Duration; use camino::{Utf8Path, Utf8PathBuf}; -use http::header::CONTENT_TYPE; +use http::header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}; use http::{Method, StatusCode}; -use http_body_util::{Either, Full}; +use http_body_util::{BodyExt as _, Either, Full, Limited}; use hyper::body::{Bytes, Incoming}; use hyper::client::conn::http1 as client_conn; use hyper::server::conn::http1 as server_conn; @@ -37,9 +42,11 @@ use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::server::{ClientHello, ResolvesServerCert}; use rustls::sign::CertifiedKey; use rustls::version::TLS13; +use serde::Serialize; use serde_json::json; use sled_hardware_types::BaseboardId; use slog::{Logger, debug, error, info, o, warn}; +use thiserror::Error; use tokio::io::{AsyncRead, AsyncWrite, copy_bidirectional}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; @@ -48,15 +55,29 @@ use tokio::{select, spawn, try_join}; use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; +use sush_common::authn::Challenge; +use sush_common::keys::SshPublicKey; use sush_common::targets::{Cubbies, SledId, Target}; use sush_common::version::VersionInfo; +use crate::login::{Logins, Unauthorized}; + /// Server addresses by baseboard. pub type Sleds = BTreeMap; /// How long to wait for a sled to accept a connection. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Cap on a login's body, which holds only an SSH public key. +const MAX_IAM_BODY_BYTES: usize = 0x1_0000; + +/// Discovery endpoints that don't require a login. +const UNAUTHENTICATED: &[(Method, &str)] = &[ + (Method::GET, "/target"), + (Method::GET, "/version"), + (Method::GET, "/versions"), +]; + /// The sush servers the proxy may route to. The sled and cubby maps are /// discovered independently (by DDM and MGS, resp.), so each arrives on /// its own channel. @@ -98,7 +119,7 @@ impl Targets { /// Backend responses pass through. Proxy errors carry their own body. type ProxyBody = Either>; -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Error)] pub enum ProxyTlsError { #[error("reading `{path}` failed: {error}")] Pem { @@ -167,7 +188,12 @@ impl ProxyServer { let listener = TcpListener::bind(local_addr).await?; let local_addr = listener.local_addr()?; let acceptor = tls.map(|config| TlsAcceptor::from(Arc::new(config))); - let router = Arc::new(Router { targets, home }); + let logins = Logins::new(log.new(o!("component" => "logins"))); + let router = Arc::new(Router { + targets, + home, + logins, + }); spawn(listen( log.new(o!("component" => "proxy")), listener, @@ -191,10 +217,11 @@ impl ProxyServer { } } -/// Pick a sled for each request. +/// Check each request's login, and pick a sled for it. struct Router { targets: Targets, home: Option, + logins: Logins, } impl Router { @@ -284,6 +311,110 @@ fn own_version(log: &Logger, request: &Request) -> Option), + /// Answer it here. + Answer(Response), +} + +/// Verify the identity claimed by a request before forwarding. +async fn check_login(logins: &Logins, request: Request) -> Checked { + // Special-case unauthenticated endpoints (see `test::unauthenticated_endpoints`). + if UNAUTHENTICATED + .iter() + .any(|(method, path)| request.method() == method && request.uri().path() == *path) + { + return Checked::Forward(request); + } + + let (head, body) = request.into_parts(); + let mut authz_headers = head.headers.get_all(AUTHORIZATION).iter(); + let authz = authz_headers.next().and_then(|value| value.to_str().ok()); + if authz_headers.next().is_some() { + return Checked::Answer(error_response( + StatusCode::BAD_REQUEST, + "Requests may have only one `Authorization` header", + )); + } + let method = head.method.as_str(); + let target = head.uri.path_and_query().map_or("", |pq| pq.as_str()); + match (&head.method, head.uri.path()) { + (&Method::POST, "/iam") => { + let public_key = match login_key(body).await { + Ok(public_key) => public_key, + Err(err) => { + return Checked::Answer(error_response( + StatusCode::BAD_REQUEST, + err.to_string(), + )); + } + }; + let result = match public_key { + Some(public_key) => logins.login(authz, public_key).await, + None => logins.authorize(authz, (method, target)).await, + }; + Checked::Answer(match result { + Ok(identity) => json_response(&identity), + Err(err) => login_refused(err), + }) + } + (&Method::GET, "/iam") => { + Checked::Answer(match logins.authorize(authz, (method, target)).await { + Ok(_) => json_response(&logins.identities().await), + Err(err) => login_refused(err), + }) + } + _ => match logins.authorize(authz, (method, target)).await { + Ok(_) => Checked::Forward(Request::from_parts(head, body)), + Err(err) => Checked::Answer(login_refused(err)), + }, + } +} + +/// Read the public key from the body of a request to `iam`. +async fn login_key(body: Incoming) -> Result, LoginKeyError> { + let body = Limited::new(body, MAX_IAM_BODY_BYTES); + let body = body.collect().await.map_err(LoginKeyError::Unreadable)?; + Ok(serde_json::from_slice(&body.to_bytes())?) +} + +#[derive(Debug, Error)] +enum LoginKeyError { + #[error("Unable to read login request body: {0}")] + Unreadable(Box), + #[error("Invalid public key: {0}")] + InvalidKey(#[from] serde_json::Error), +} + +/// Answer a refused login with a fresh challenge. +fn login_refused(Unauthorized(nonce): Unauthorized) -> Response { + let mut response = error_response( + StatusCode::UNAUTHORIZED, + "Authentication required, try `iam`", + ); + let challenge = Challenge::new(nonce).to_string(); + response.headers_mut().insert( + WWW_AUTHENTICATE, + challenge + .parse() + .expect("challenge should be a valid header"), + ); + response +} + +fn json_response(value: &impl Serialize) -> Response { + match serde_json::to_vec(value) { + Ok(body) => Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Either::Right(Full::new(body.into()))) + .expect("JSON response should build"), + Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + async fn listen( log: Logger, listener: TcpListener, @@ -334,18 +465,22 @@ where let log = log.clone(); let router = router.clone(); async move { - Ok::<_, Infallible>(match own_version(&log, &request) { - Some(response) => response, - None => match router.route(&request) { - Ok(addr) => forward(log, addr, request).await, - Err(response) => { - warn!( - log, "request not routable"; - "uri" => %request.uri(), "status" => %response.status() - ); - *response - } - }, + if let Some(response) = own_version(&log, &request) { + return Ok::<_, Infallible>(response); + } + let request = match check_login(&router.logins, request).await { + Checked::Forward(request) => request, + Checked::Answer(response) => return Ok(response), + }; + Ok(match router.route(&request) { + Ok(addr) => forward(log, addr, request).await, + Err(response) => { + warn!( + log, "request not routable"; + "uri" => %request.uri(), "status" => %response.status() + ); + *response + } }) } }); @@ -427,8 +562,42 @@ fn error_response(status: StatusCode, message: impl Into) -> Response = UNAUTHENTICATED + .iter() + .map(|(method, path)| (method.to_string(), path.to_string())) + .collect(); + assert_eq!(unauthenticated, expected); + } + fn request(path_and_query: &str) -> Request<()> { Request::builder().uri(path_and_query).body(()).unwrap() } @@ -464,9 +633,11 @@ mod test { sleds.insert(sled("away"), addr(1)); sleds.insert(sled("home"), addr(2)); }); + let logins = || Logins::new(Logger::root(slog::Discard, o!())); let router = Router { targets: targets.clone(), home: Some(sled("home")), + logins: logins(), }; let get = request("/versions"); assert_eq!(router.route(&get).unwrap(), addr(2)); @@ -484,6 +655,7 @@ mod test { let homeless = Router { targets, home: None, + logins: logins(), }; assert_eq!(homeless.route(&get).unwrap(), addr(1)); } diff --git a/proxy/src/login.rs b/proxy/src/login.rs new file mode 100644 index 00000000..5b9fe305 --- /dev/null +++ b/proxy/src/login.rs @@ -0,0 +1,332 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Verify client identities. Servers don't check claimed identities; +//! they trust that we (the proxy) have performed this verification. +//! This trust is warranted by the fact that *anything* on the underlay +//! or bootstrap network is currently trusted, and in the future will +//! be authenticated via sprockets-backed mTLS. + +use std::num::NonZeroUsize; +use std::time::Instant; + +use chrono::Utc; +use lru::LruCache; +use slog::{Logger, debug, warn}; +use thiserror::Error; +use tokio::sync::Mutex; + +use sush_common::authn::{ + Authn, AuthnError, BoundCredentials, BoundRequest, Credentials, IDENTITY_IDLE_TTL, + IDENTITY_MAX_TTL, Identity, NONCE_TTL, Nonce, RequestVerifier, SeqWindow, +}; +use sush_common::keys::{KeyError, KeyId, SshPublicKey}; + +/// Maximum number of cached identities. +const MAX_CACHED_IDENTITIES: NonZeroUsize = NonZeroUsize::new(1_000).unwrap(); + +/// Maximum number of outstanding authentication nonces. Every failed +/// request mints one, so garbage traffic evicts real challenges. This +/// is sized so a flood must sustain hundreds of requests per second +/// for the whole nonce TTL to lock a user out. +const MAX_OUTSTANDING_NONCES: NonZeroUsize = NonZeroUsize::new(10_000).unwrap(); + +/// An authenticated identity and the state that authorizes its +/// requests: the ephemeral verifier, the spent sequence numbers, and +/// its lifetime, kept on the monotonic clock because the wall clock +/// may step by decades when NTP first syncs. +#[derive(Debug)] +struct CachedIdentity { + identity: Identity, + verifier: RequestVerifier, + window: SeqWindow, + authenticated: Instant, + last_used: Instant, +} + +impl CachedIdentity { + fn is_still_valid(&self) -> bool { + self.authenticated.elapsed() < IDENTITY_MAX_TTL + && self.last_used.elapsed() < IDENTITY_IDLE_TTL + } +} + +/// A fresh challenge nonce in response to a denied request. +#[derive(Debug)] +pub struct Unauthorized(pub Nonce); + +/// Why a login or a request was refused. +#[derive(Debug, Error)] +enum LoginError { + #[error("missing authorization")] + MissingAuthorization, + #[error(transparent)] + Authn(#[from] AuthnError), + #[error(transparent)] + Key(#[from] KeyError), + #[error("bound credentials presented at login")] + BoundAtLogin, + #[error("initial credentials are only valid at login")] + InitialOutsideLogin, + #[error("nonce not found")] + NonceNotFound, + #[error("nonce expired")] + NonceExpired, + #[error("key ID does not match the public key")] + KeyIdMismatch, + #[error("not logged in")] + NotLoggedIn, + #[error("unknown identity")] + UnknownIdentity, + #[error("identity expired")] + IdentityExpired, + #[error("sequence number already spent")] + SeqSpent, +} + +/// Outstanding challenge nonces and logged-in identities. +/// NB: All tables must have a fixed maximum size! +#[derive(Debug)] +pub struct Logins { + log: Logger, + nonces: Mutex>, + identities: Mutex>, +} + +impl Logins { + pub fn new(log: Logger) -> Self { + Self { + log, + nonces: Mutex::new(LruCache::new(MAX_OUTSTANDING_NONCES)), + identities: Mutex::new(LruCache::new(MAX_CACHED_IDENTITIES)), + } + } + + /// Authenticate initial credentials for `public_key`, and cache the + /// identity they prove. + pub async fn login( + &self, + authz: Option<&str>, + public_key: SshPublicKey, + ) -> Result { + let result = self.verify_login(authz, public_key).await; + self.refuse_failure(result).await + } + + /// Authorize a bound request against a cached identity. `request` + /// is the method and target exactly as received. + pub async fn authorize( + &self, + authz: Option<&str>, + request: (&str, &str), + ) -> Result { + let result = self.verify_request(authz, request).await; + self.refuse_failure(result).await + } + + /// Log why authentication failed, and answer with a fresh challenge. + /// Every failure must come through here, so that the client can + /// log in again. + async fn refuse_failure(&self, result: Result) -> Result { + let error = match result { + Ok(value) => return Ok(value), + Err(error) => error, + }; + warn!(self.log, "authentication failed"; "error" => %error); + let nonce = Nonce::random(); + self.nonces.lock().await.put(nonce.clone(), Instant::now()); + Err(Unauthorized(nonce)) + } + + async fn verify_login( + &self, + authz: Option<&str>, + public_key: SshPublicKey, + ) -> Result { + let authz = authz.ok_or(LoginError::MissingAuthorization)?; + let Authn::Initial(credentials) = authz.parse()? else { + return Err(LoginError::BoundAtLogin); + }; + let Credentials { key_id, nonce, .. } = *credentials.clone(); + + // Claim the nonce. + let minted = self + .nonces + .lock() + .await + .pop(&nonce) + .ok_or(LoginError::NonceNotFound)?; + if minted.elapsed() >= NONCE_TTL { + return Err(LoginError::NonceExpired); + } + + // Verify the supplied credentials. + if public_key.key_id().ok() != Some(key_id.clone()) { + return Err(LoginError::KeyIdMismatch); + } + let response = credentials.clone().into_challenge_response(); + let verified = response.verify_with_ssh_public_key(&public_key)?; + let identity = Identity::new(public_key, verified, Utc::now())?; + + // Authenticated! Cache the identity and its request verifier. + debug!(self.log, "authenticated credentials for identity"; "key_id" => %key_id); + self.identities.lock().await.put( + (key_id, nonce), + CachedIdentity { + identity: identity.clone(), + verifier: credentials.epk, + window: SeqWindow::default(), + authenticated: Instant::now(), + last_used: Instant::now(), + }, + ); + Ok(identity) + } + + async fn verify_request( + &self, + authz: Option<&str>, + request: (&str, &str), + ) -> Result { + let authz = authz.ok_or(LoginError::MissingAuthorization)?; + let Authn::Bound(BoundCredentials { key_id, signature }) = authz.parse()? else { + return Err(LoginError::InitialOutsideLogin); + }; + let proof = signature.ok_or(LoginError::NotLoggedIn)?; + let mut identities = self.identities.lock().await; + let cache_key = (key_id.clone(), proof.nonce.clone()); + let cached = identities + .get_mut(&cache_key) + .ok_or(LoginError::UnknownIdentity)?; + if !cached.is_still_valid() { + identities.pop(&cache_key); + return Err(LoginError::IdentityExpired); + } + let (method, target) = request; + let received = BoundRequest::new(method, target, proof.seq); + cached.verifier.verify(&received, &key_id, &proof)?; + if !cached.window.spend(proof.seq) { + return Err(LoginError::SeqSpent); + } + cached.last_used = Instant::now(); + debug!(self.log, "bound request authorized"; "key_id" => %key_id); + Ok(cached.identity.clone()) + } + + /// The identities whose logins have not expired. + pub async fn identities(&self) -> Vec { + self.identities + .lock() + .await + .iter() + .filter(|(_, cached)| cached.is_still_valid()) + .map(|(_, cached)| cached.identity.to_owned()) + .collect() + } +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use slog::o; + use x509_cert::time::Validity; + + use sush_common::authn::{Challenge, ChallengeResponse, RequestKey}; + use sush_common::keys::{EphemeralKey, KeyType, Signer as _}; + + use super::*; + + #[tokio::test] + async fn logins() { + let logins = Logins::new(Logger::root(slog::Discard, o!())); + let subject = "CN=Login Test Key,O=Oxide Computer Company,C=US"; + let mut root = EphemeralKey::new_root( + KeyType::P256, + subject.parse().unwrap(), + Validity::from_now(Duration::from_secs(60)).unwrap(), + ) + .unwrap(); + let Unauthorized(nonce) = logins + .authorize(None, ("GET", "/sessions")) + .await + .unwrap_err(); + + // Construct credentials. + let challenge = Challenge::new(nonce.clone()); + let request_key = RequestKey::new(); + let response = ChallengeResponse::new(challenge, request_key.verifier()); + let signed = root.sign(response).await.unwrap(); + let verified = signed.verify_with_cert(root.cert()).unwrap(); + let mut credentials = Credentials::new(verified); + let public_key = root.ssh_public_key(); + let key_id = public_key.key_id().unwrap(); + credentials.key_id = key_id.clone(); // override cert key ID + let initial = credentials.to_string(); + let bind = |method, target, seq| { + let request = BoundRequest::new(method, target, seq); + request_key + .bind(key_id.clone(), credentials.nonce.clone(), &request) + .to_string() + }; + + // Claiming a key is not a login. + let claimed = BoundCredentials::claim(key_id.clone()).to_string(); + assert!( + logins + .authorize(Some(&claimed), ("GET", "/sessions")) + .await + .is_err() + ); + + // Register our identity. Initial credentials work only to log in. + assert!( + logins + .authorize(Some(&initial), ("GET", "/sessions")) + .await + .is_err() + ); + let identity = logins + .login(Some(&initial), public_key.clone()) + .await + .unwrap(); + let Identity { + key_id: iam_key_id, + public_key: iam_public_key, + nonce: iam_nonce, + time_authenticated: iam_authenticated, + } = identity; + assert_eq!(iam_key_id, key_id); + assert_eq!(iam_public_key, public_key); + assert_eq!(iam_nonce, credentials.nonce); + assert!(iam_authenticated <= Utc::now()); + + // A bound request authorizes; replaying its header does not, nor + // does presenting it against a different request line. + let header = bind("GET", "/sessions", 1); + let sessions = ("GET", "/sessions"); + let authorized = logins.authorize(Some(&header), sessions).await.unwrap(); + assert_eq!(authorized.key_id, key_id); + assert!(logins.authorize(Some(&header), sessions).await.is_err()); + let header = bind("GET", "/sessions", 2); + assert!( + logins + .authorize(Some(&header), ("GET", "/jobs")) + .await + .is_err() + ); + + // Bound credentials cannot log in. + let header = bind("POST", "/iam", 3); + assert!( + logins + .login(Some(&header), public_key.clone()) + .await + .is_err() + ); + + // Replayed initial credentials are rejected: the nonce is spent. + assert!(logins.login(Some(&initial), public_key).await.is_err()); + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 9701844a..b3898e8d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,17 +31,14 @@ http.workspace = true http-body-util.workspace = true http-range-header.workspace = true hyper.workspace = true -hyper-util.workspace = true libc.workspace = true lru.workspace = true memmap2.workspace = true p256.workspace = true -percent-encoding.workspace = true pwd.workspace = true rumors.workspace = true rand_core.workspace = true rustix.workspace = true -rustls.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -56,7 +53,6 @@ sush-common = { path = "../common" } tempfile.workspace = true thiserror.workspace = true tokio.workspace = true -tokio-rustls.workspace = true tokio-stream.workspace = true tokio-util.workspace = true tokio-tungstenite.workspace = true diff --git a/server/src/error.rs b/server/src/error.rs index 4f63d338..c2baaa99 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -13,7 +13,6 @@ use dropshot::{ClientErrorStatusCode, HttpError}; use thiserror::Error; use sush_api::ErrorCode; -use sush_common::authn::{Challenge, Nonce}; use sush_common::interactive::InteractiveJobError; use sush_common::jobs::{ExecutionError, JobId, JobOutputHash, SessionId}; use sush_common::keys::{KeyError, KeyId}; @@ -71,6 +70,8 @@ pub enum JobError { MultipleSessions, #[error("No sled this job targets is reachable")] NoReachableTarget, + #[error("Requests must include an identity, is `SSH_AUTH_SOCK` set?")] + NoRequester, #[error("No current session, try `session start`")] NoSession, #[error("Job `{0}` is not the session's next job")] @@ -89,6 +90,8 @@ pub enum JobError { PublicKeyMismatch(KeyId), #[error("Public key `{0}` not found")] PublicKeyNotFound(KeyId), + #[error("Only available at proxy servers")] + ProxyOnly, #[error(transparent)] Slice(#[from] std::array::TryFromSliceError), #[error(transparent)] @@ -97,8 +100,6 @@ pub enum JobError { Timeout, #[error("Too many simultaneous output requests ({0})")] TooManyOutputRequests(usize), - #[error("Unauthorized request")] - Unauthorized(Nonce), #[error("Which sled is in cubby {0} has not yet been reported")] UnknownCubby(u8), #[error("Unable to wait for job end")] @@ -114,10 +115,6 @@ impl JobError { Self::ChannelClosed } - pub fn unauthorized(nonce: Nonce) -> Self { - Self::Unauthorized(nonce) - } - /// Report I/O errors with the corresponding stream name. pub fn io(what: impl AsRef, error: std::io::Error) -> Self { Self::Io { @@ -185,17 +182,6 @@ impl From for HttpError { ClientErrorStatusCode::TOO_MANY_REQUESTS, error.to_string(), ), - Unauthorized(nonce) => { - let mut err = HttpError::for_client_error( - None, - ClientErrorStatusCode::UNAUTHORIZED, - String::from("Authentication required, try `iam`"), - ); - let challenge = Challenge::new(nonce); - err.add_header("WWW-Authenticate", challenge) - .expect("should be able to add WWW-Authenticate header"); - err - } AttachDenied | NotSessionStarter => { HttpError::for_client_error(None, ClientErrorStatusCode::FORBIDDEN, message) } @@ -213,9 +199,13 @@ impl From for HttpError { message, ), DuplicateJobId(_) | InteractiveTarget | InvalidCommand | Json(_) | MultipleSessions - | InvalidSessionId | NoReachableTarget | UnknownCubby(_) | UnverifiedJob(_) => { + | InvalidSessionId | NoReachableTarget | NoRequester | UnknownCubby(_) + | UnverifiedJob(_) => { HttpError::for_client_error(None, ClientErrorStatusCode::BAD_REQUEST, message) } + ProxyOnly => { + HttpError::for_client_error(None, ClientErrorStatusCode::NOT_FOUND, message) + } NotNextJob(_) => { HttpError::for_client_error(None, ClientErrorStatusCode::CONFLICT, message) } diff --git a/server/src/identities.rs b/server/src/identities.rs deleted file mode 100644 index 506a62f4..00000000 --- a/server/src/identities.rs +++ /dev/null @@ -1,155 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. - -//! SSH key based identities for login and request attribution. - -use std::num::NonZeroUsize; - -use chrono::Utc; -use lru::LruCache; -use slog::{Logger, error, info}; - -use sush_common::authn::{Identity, Nonce, RequestVerifier, SignedLogin}; -use sush_common::keys::{KeyError, KeyId, SshPublicKey}; - -use crate::messages::v0::IdentityRequest; - -/// Maximum number of registered identities. -const MAX_REGISTERED_IDENTITIES: NonZeroUsize = NonZeroUsize::new(1_000).unwrap(); - -/// A rack-wide record of a verified login. -#[derive(Clone, Debug)] -pub struct RegisteredIdentity { - pub identity: Identity, - pub verifier: RequestVerifier, -} - -#[derive(Debug)] -pub struct Identities { - /// Verified logins, rack-wide. - identities: LruCache<(KeyId, Nonce), RegisteredIdentity>, -} - -impl Identities { - pub fn new() -> Self { - Self { - identities: LruCache::new(MAX_REGISTERED_IDENTITIES), - } - } - - pub fn registered_identity(&self, key: &(KeyId, Nonce)) -> Option<&RegisteredIdentity> { - self.identities.peek(key) - } - - pub fn apply(&mut self, log: &Logger, request: &IdentityRequest) { - match request { - IdentityRequest::Login(openssh, signed) => { - match SshPublicKey::from_openssh(openssh) - .and_then(|public_key| verify_login(&public_key, signed)) - { - Ok(registered) => { - let identity = ®istered.identity; - info!(log, "registered identity"; "key_id" => %identity.key_id); - let key = (identity.key_id.clone(), identity.nonce.clone()); - self.identities.put(key, registered); - } - Err(error) => { - error!(log, "ignoring invalid login"; "error" => %error); - } - } - } - } - } -} - -impl Default for Identities { - fn default() -> Self { - Self::new() - } -} - -/// Locally verify gossiped login evidence. -fn verify_login( - public_key: &SshPublicKey, - signed: &SignedLogin, -) -> Result { - let verified = signed.clone().verify_with_ssh_public_key(public_key)?; - let verifier = verified.epk().clone(); - let identity = Identity::new(public_key.clone(), verified, Utc::now())?; - Ok(RegisteredIdentity { identity, verifier }) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use slog::{Discard, o}; - use x509_cert::time::Validity; - - use sush_common::authn::{Challenge, ChallengeResponse, RequestKey}; - use sush_common::keys::{EphemeralKey, KeyType, Signer as _}; - - use super::*; - - fn log() -> Logger { - Logger::root(Discard, o!()) - } - - fn key(name: &str) -> EphemeralKey { - let subject = format!("CN={name},O=Oxide Computer Company,C=US"); - let validity = Validity::from_now(Duration::from_secs(60)).unwrap(); - EphemeralKey::new_root(KeyType::P256, subject.parse().unwrap(), validity).unwrap() - } - - fn key_id(key: &EphemeralKey) -> KeyId { - key.ssh_public_key().key_id().unwrap() - } - - async fn login(key: &mut EphemeralKey) -> (Nonce, IdentityRequest) { - let nonce = Nonce::random(); - let challenge = Challenge::new(nonce.clone()); - let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); - let signed = key.sign(response).await.unwrap(); - let openssh = key.ssh_public_key().to_openssh().unwrap(); - (nonce, IdentityRequest::Login(openssh, signed)) - } - - #[tokio::test] - async fn login_registers_identity() { - let mut key = key("Operator"); - let mut identities = Identities::new(); - let (nonce, request) = login(&mut key).await; - identities.apply(&log(), &request); - assert!( - identities - .registered_identity(&(key_id(&key), nonce)) - .is_some() - ); - } - - #[tokio::test] - async fn hostile_logins_ignored() { - let mut signer = key("Signer"); - let bystander = key("Bystander"); - let mut identities = Identities::new(); - let (nonce, request) = login(&mut signer).await; - let IdentityRequest::Login(_, signed) = &request; - let forged = IdentityRequest::Login( - bystander.ssh_public_key().to_openssh().unwrap(), - signed.clone(), - ); - identities.apply(&log(), &forged); - identities.apply( - &log(), - &IdentityRequest::Login("junk".into(), signed.clone()), - ); - for key in [key_id(&signer), key_id(&bystander)] { - assert!( - identities - .registered_identity(&(key, nonce.clone())) - .is_none() - ); - } - } -} diff --git a/server/src/lib.rs b/server/src/lib.rs index 20546ce3..41a14837 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -18,7 +18,6 @@ pub mod executor; pub mod format; pub mod gossip; pub mod history; -pub mod identities; pub mod io; pub mod job; pub mod link; @@ -27,7 +26,6 @@ pub mod manager; pub mod messages; pub mod mux; pub mod output; -pub mod proxy; pub mod pty; pub mod rack; pub mod server; @@ -36,6 +34,5 @@ pub mod state; pub use error::JobError; pub use manager::{JobManager, read_certs}; -pub use proxy::ProxyServer; pub use server::ApiServer; pub use state::seed_gossip; diff --git a/server/src/manager.rs b/server/src/manager.rs index 9bfb4aa0..98a3eff6 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -2,23 +2,20 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -//! Manage authentication, job signature verification, and the session -//! state machine. Does not manage jobs directly. +//! Manage job signature verification and the session state machine. +//! Does not manage jobs directly. use std::collections::BTreeSet; -use std::num::NonZeroUsize; use std::path::Path; use std::sync::{Arc, Mutex as SyncMutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; -use chrono::Utc; use http_range_header::SyntacticallyCorrectRange as Range; -use lru::LruCache; use sled_hardware_types::BaseboardId; -use slog::{Logger, debug, info, o, warn}; +use slog::{Logger, info, o}; use tokio::fs::read; use tokio::select; -use tokio::sync::{Mutex, mpsc, watch}; +use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; use tokio::time::timeout; use tokio_stream::wrappers::ReceiverStream; @@ -27,16 +24,12 @@ use x509_cert::Certificate; use x509_cert::der::DecodePem as _; use sush_api::{JobStartParams, JobStopParams, JobWait}; -use sush_common::authn::{ - Authn, BoundRequest, Credentials, IDENTITY_IDLE_TTL, IDENTITY_MAX_TTL, Identity, NONCE_TTL, - Nonce, RequestVerifier, SeqWindow, -}; use sush_common::jobs::{ Access, JobId, JobOutputStream, JobStatusMap, Session, SessionClientNonce, SessionId, SessionPreimage, SessionSeq, SessionServerNonce, SessionSignerNonce, SessionSushNonce, SignedJob, }; -use sush_common::keys::{KeyError, KeyId, SshPublicKey}; +use sush_common::keys::{KeyError, KeyId}; use sush_common::targets::{Cubbies, SledHealth, SledVersion, Target, UnknownCubby}; use sush_common::version::LONG_VERSION; @@ -46,49 +39,16 @@ use crate::gossip::LinkedBaseboards; use crate::job::SocketSender; use crate::locker::Locker; use crate::locker::boundary::BoundaryStore; -use crate::messages::v0::{IdentityRequest, JobRequest, Request, SessionRequest}; +use crate::messages::v0::{JobRequest, Request, SessionRequest}; use crate::output::{JobOutputDir, JobOutputFileStream}; use crate::state::{GossipUniverse, State, StateManager}; -/// Maximum number of cached identities. -const MAX_CACHED_IDENTITIES: NonZeroUsize = NonZeroUsize::new(1_000).unwrap(); - -/// Maximum number of outstanding authentication nonces. Every failed -/// request mints one, so garbage traffic evicts real challenges. This -/// is sized so a flood must sustain hundreds of requests per second -/// for the whole nonce TTL to lock a user out. -const MAX_OUTSTANDING_NONCES: NonZeroUsize = NonZeroUsize::new(10_000).unwrap(); - /// Maximum amount of time we're willing to wait for job start or stop. const WAIT_TIMEOUT: Duration = Duration::from_secs(600); -/// An authenticated identity and the state that authorizes its -/// requests: the ephemeral verifier, the spent sequence numbers, and -/// its lifetime, kept on the monotonic clock because the wall clock -/// may step by decades when NTP first syncs. #[derive(Debug)] -struct CachedIdentity { - identity: Identity, - verifier: RequestVerifier, - window: SeqWindow, - authenticated: Instant, - last_used: Instant, -} - -impl CachedIdentity { - fn is_still_valid(&self) -> bool { - self.authenticated.elapsed() < IDENTITY_MAX_TTL - && self.last_used.elapsed() < IDENTITY_IDLE_TTL - } -} - -/// NB: All tables must have a fixed maximum size! -#[derive(Debug)] -#[allow(clippy::type_complexity)] pub struct JobManager { log: Logger, - nonces: Arc>>, - identities: Arc>>, session_server_nonce: Arc>, output_dir: JobOutputDir, own_baseboard: BaseboardId, @@ -163,8 +123,6 @@ impl JobManager { )?; Ok(Self { log: log.new(o!("component" => "job manager")), - nonces: Arc::new(Mutex::new(LruCache::new(MAX_OUTSTANDING_NONCES))), - identities: Arc::new(Mutex::new(LruCache::new(MAX_CACHED_IDENTITIES))), session_server_nonce, own_baseboard, linked, @@ -210,190 +168,28 @@ impl JobManager { async fn session_request( &self, - authn: &Identity, + authn: &KeyId, request: SessionRequest, ) -> Result<(), JobError> { self.tx_req - .send(Request::session(authn.key_id.clone(), request)) + .send(Request::session(authn.clone(), request)) .await .map_err(|_| JobError::ChannelClosed) } - async fn job_request(&self, authn: &Identity, request: JobRequest) -> Result<(), JobError> { + async fn job_request(&self, authn: &KeyId, request: JobRequest) -> Result<(), JobError> { self.tx_req - .send(Request::job(authn.key_id.clone(), request)) - .await - .map_err(|_| JobError::ChannelClosed) - } - - async fn identity_request( - &self, - actor: KeyId, - request: IdentityRequest, - ) -> Result<(), JobError> { - self.tx_req - .send(Request::identity(actor, request)) + .send(Request::job(authn.clone(), request)) .await .map_err(|_| JobError::ChannelClosed) } // Certificate management. - pub fn cert_chain( - &self, - _authn: &Identity, - key_id: &KeyId, - ) -> Result, JobError> { + pub fn cert_chain(&self, _authn: &KeyId, key_id: &KeyId) -> Result, JobError> { Ok(self.state.borrow().certs().chain(key_id)?) } - // Identity management. - - /// Authenticate initial credentials at `iam`, or authorize a bound - /// request against a cached identity. `request` is the request - /// line: the method and the target exactly as received. - pub async fn iam( - &self, - authorization: Option, - public_key: Option, - request: (&str, &str), - ) -> Result { - // NB: Do not use the `?` operator in this function! We must ensure - // that all authentication failures are logged and receive a proper - // 401 Unauthorized response with a fresh nonce. - macro_rules! unauthorized { - ($error:expr) => {{ - warn!(self.log, "authentication failed"; "error" => %$error); - let nonce = Nonce::random(); - self.nonces.lock().await.put(nonce.clone(), Instant::now()); - return Err(JobError::unauthorized(nonce)); - }}; - } - - macro_rules! try_authn { - // $expr -> Result - ($expr:expr) => { - match $expr { - Ok(value) => value, - Err(error) => unauthorized!(error), - } - }; - // $expr -> bool - ($expr:expr, $error:expr) => { - $expr || unauthorized!($error) - }; - } - - // Parse the supplied credentials. - let Some(authorization) = authorization else { - unauthorized!("missing authorization") - }; - let now = Utc::now(); - let credentials = match try_authn!(authorization.parse::()) { - // A bound request authorizes against a cached identity. - Authn::Bound(bound) => { - let mut identities = self.identities.lock().await; - let cache_key = (bound.key_id.clone(), bound.nonce.clone()); - // A login elsewhere on the rack reaches us as a - // registered identity. Adopt it on first use. - if !identities.contains(&cache_key) { - let Some(registered) = self - .state - .borrow() - .identities() - .registered_identity(&cache_key) - .cloned() - else { - unauthorized!("unknown identity"); - }; - debug!(self.log, "adopting registered identity"; "key_id" => %bound.key_id); - identities.put( - cache_key.clone(), - CachedIdentity { - identity: registered.identity, - verifier: registered.verifier, - window: SeqWindow::default(), - authenticated: Instant::now(), - last_used: Instant::now(), - }, - ); - } - let Some(cached) = identities.get_mut(&cache_key) else { - unauthorized!("unknown identity"); - }; - if !cached.is_still_valid() { - identities.pop(&cache_key); - unauthorized!("identity expired"); - } - let (method, target) = request; - let received = BoundRequest::new(method, target, bound.seq); - try_authn!(cached.verifier.verify(&received, &bound)); - if !cached.window.spend(bound.seq) { - unauthorized!("sequence number already spent"); - } - cached.last_used = Instant::now(); - debug!(self.log, "bound request authorized"; "key_id" => %bound.key_id); - return Ok(cached.identity.clone()); - } - Authn::Initial(credentials) => *credentials, - }; - let Credentials { key_id, nonce, .. } = credentials.clone(); - - // Initial credentials authenticate only at `iam` itself. - let (method, target) = request; - if method != "POST" || target.split('?').next() != Some("/iam") { - unauthorized!("initial credentials presented outside iam"); - } - - // Claim the nonce. - let Some(minted) = self.nonces.lock().await.pop(&nonce) else { - unauthorized!("nonce not found"); - }; - if minted.elapsed() >= NONCE_TTL { - unauthorized!("nonce expired"); - }; - - // Verify the supplied credentials. - let Some(public_key) = public_key else { - unauthorized!("missing public key"); - }; - if public_key.key_id().ok() != Some(key_id.clone()) { - unauthorized!("invalid key ID"); - } - let response = credentials.clone().into_challenge_response(); - let verified = try_authn!(response.clone().verify_with_ssh_public_key(&public_key)); - let identity = try_authn!(Identity::new(public_key.clone(), verified, now)); - - // Authenticated! Cache the identity and its request verifier, - // and gossip the evidence. - debug!(self.log, "authenticated credentials for identity"; "key_id" => %key_id); - self.identities.lock().await.put( - (key_id.clone(), nonce), - CachedIdentity { - identity: identity.clone(), - verifier: credentials.epk, - window: SeqWindow::default(), - authenticated: Instant::now(), - last_used: Instant::now(), - }, - ); - let login = IdentityRequest::Login(public_key.to_openssh()?, response); - self.identity_request(key_id, login) - .await - .map(|()| identity) - } - - pub async fn identities(&self, _authn: &Identity) -> Result, JobError> { - Ok(self - .identities - .lock() - .await - .iter() - .filter(|(_, cached)| cached.is_still_valid()) - .map(|(_, cached)| cached.identity.to_owned()) - .collect()) - } - // Waiting. pub fn take_join_handle(&mut self) -> Option> { @@ -460,11 +256,11 @@ impl JobManager { // Session management. - pub fn session(&self, _authn: &Identity) -> Option { + pub fn session(&self, _authn: &KeyId) -> Option { self.state.borrow().session().cloned() } - pub async fn session_id(&self, authn: &Identity) -> Result { + pub async fn session_id(&self, authn: &KeyId) -> Result { let Some(session) = self.session(authn) else { return Err(JobError::NoSession); }; @@ -482,7 +278,7 @@ impl JobManager { pub async fn session_start( &self, - authn: &Identity, + authn: &KeyId, session_id: SessionId, client_nonce: SessionClientNonce, signer_nonce: SessionSignerNonce, @@ -527,11 +323,7 @@ impl JobManager { }) } - pub async fn session_stop( - &self, - authn: &Identity, - session_id: SessionId, - ) -> Result<(), JobError> { + pub async fn session_stop(&self, authn: &KeyId, session_id: SessionId) -> Result<(), JobError> { self.starter_check(authn)?; self.currency_check(session_id)?; self.session_request(authn, SessionRequest::Stop(session_id)) @@ -542,7 +334,7 @@ impl JobManager { /// requests that lost a race. This check fails fast and loudly. pub async fn session_skip_job( &self, - authn: &Identity, + authn: &KeyId, session_id: SessionId, job_id: JobId, ) -> Result<(), JobError> { @@ -572,10 +364,10 @@ impl JobManager { /// Only the session starter may stop the session, skip its jobs, /// or grant and deny attach access. The authoritative check is in /// the state machine. This one fails fast. - fn starter_check(&self, authn: &Identity) -> Result<(), JobError> { + fn starter_check(&self, authn: &KeyId) -> Result<(), JobError> { match self.state.borrow().session() { None => Err(JobError::NoSession), - Some(session) if session.started_by() == Some(&authn.key_id) => Ok(()), + Some(session) if session.started_by() == Some(authn) => Ok(()), Some(_) => Err(JobError::NotSessionStarter), } } @@ -591,7 +383,7 @@ impl JobManager { pub async fn session_allow_attach( &self, - authn: &Identity, + authn: &KeyId, session_id: SessionId, key_id: KeyId, access: Access, @@ -607,7 +399,7 @@ impl JobManager { pub async fn session_deny_attach( &self, - authn: &Identity, + authn: &KeyId, session_id: SessionId, key_id: KeyId, ) -> Result<(), JobError> { @@ -624,7 +416,7 @@ impl JobManager { /// of them, and return them. pub async fn job_start( &self, - authn: &Identity, + authn: &KeyId, job: SignedJob, params: JobStartParams, ) -> Result, JobError> { @@ -677,7 +469,7 @@ impl JobManager { pub async fn job_stop( &self, - authn: &Identity, + authn: &KeyId, job_id: &JobId, JobStopParams { wait, .. }: JobStopParams, ) -> Result<(), JobError> { @@ -696,7 +488,7 @@ impl JobManager { pub async fn job_status( &self, - _authn: &Identity, + _authn: &KeyId, job_id: &JobId, ) -> Result { if let Some(status) = self.state.borrow().get_job_status(job_id) { @@ -708,7 +500,7 @@ impl JobManager { pub async fn job_output( &self, - authn: &Identity, + authn: &KeyId, job_id: &JobId, target: &BaseboardId, stream: JobOutputStream, @@ -724,7 +516,7 @@ impl JobManager { { info!( self.log, "job output read"; - "job_id" => %job_id, "stream" => %stream, "actor" => %authn.key_id, + "job_id" => %job_id, "stream" => %stream, "actor" => %authn, ); self.output_dir.job_output(job_id, stream, range).await } else { @@ -734,11 +526,11 @@ impl JobManager { pub async fn job_attachment( &self, - authn: &Identity, + authn: &KeyId, job_id: &JobId, target: &BaseboardId, ) -> Result<(SocketSender, Access), JobError> { - let Some(access) = self.state.borrow().attach_access(&authn.key_id) else { + let Some(access) = self.state.borrow().attach_access(authn) else { return Err(JobError::AttachDenied); }; if self.own_baseboard() == target @@ -746,7 +538,7 @@ impl JobManager { { info!( self.log, "job attach"; - "job_id" => %job_id, "access" => ?access, "actor" => %authn.key_id, + "job_id" => %job_id, "access" => ?access, "actor" => %authn, ); Ok((attachment.to_owned(), access)) } else { @@ -756,7 +548,7 @@ impl JobManager { pub async fn job_history( &self, - _authn: &Identity, + _authn: &KeyId, limit: u32, offset: u32, ) -> Result, JobError> { diff --git a/server/src/messages.rs b/server/src/messages.rs index b7d84a70..5e2efd6c 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -12,7 +12,6 @@ use serde::{Deserialize, Serialize}; use sled_hardware_types::BaseboardId; use sush_api::JobStartParams; -use sush_common::authn::SignedLogin; use sush_common::jobs::JobOutputState; use sush_common::jobs::{ Access, JobId, ProcessError, SessionId, SessionPreimage, SignedJob, SkipReason, @@ -169,7 +168,6 @@ pub mod v0 { pub enum Request { Session(Box>), Job(Box>), - Identity(Box>), } impl Request { @@ -181,15 +179,10 @@ pub mod v0 { Self::Job(Box::new(Attributed { actor, request })) } - pub fn identity(actor: KeyId, request: IdentityRequest) -> Self { - Self::Identity(Box::new(Attributed { actor, request })) - } - pub fn actor(&self) -> &KeyId { match self { Self::Session(r) => r.as_parts().0, Self::Job(r) => r.as_parts().0, - Self::Identity(r) => r.as_parts().0, } } @@ -206,9 +199,6 @@ pub mod v0 { JobRequest::Start(..) => "job start", JobRequest::Stop(_) => "job stop", }, - Self::Identity(r) => match r.request { - IdentityRequest::Login(..) => "identity login", - }, } } } @@ -229,16 +219,6 @@ pub mod v0 { Stop(JobId), } - /// Identity gossip carries evidence, not assertions: every sled - /// verifies a login's challenge-response signature itself, so a - /// registered identity never depends on another sled's honesty. - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] - #[allow(clippy::large_enum_variant)] - pub enum IdentityRequest { - /// OpenSSH public keys are string encoded and decoded at use. - Login(String, SignedLogin), - } - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Event { Job(JobEvent), @@ -277,7 +257,6 @@ mod wire_format { use std::fs::{read, read_dir, write}; use sush_api::JobWait; - use sush_common::authn::{ChallengeResponse, Nonce, RequestVerifier}; use sush_common::hash::hash; use sush_common::jobs::{ JobMode, JobStartRequest, SessionSeq, SessionSignerNonce, SessionSushNonce, @@ -341,9 +320,6 @@ mod wire_format { JobRequest::Start(..) => "job-start-request", JobRequest::Stop(_) => "job-stop-request", }, - Message::Request(Request::Identity(r)) => match r.request { - IdentityRequest::Login(..) => "identity-login-request", - }, Message::Event(_, event) => match event { Event::Version(_) => "version-event", Event::Job(JobEvent::Start(..)) => "job-start-event", @@ -418,19 +394,6 @@ mod wire_format { ..Default::default() }; - // Deterministic login evidence: nonces, then the ed25519 - // basepoint as the verifier. - let mut epk = [0x66; 32]; - epk[0] = 0x58; - let response: ChallengeResponse = serde_json::from_value(serde_json::json!({ - "nonce": "abandon".parse::().unwrap(), - "cnonce": "ability".parse::().unwrap(), - "epk": RequestVerifier::from_be_bytes(epk), - })) - .unwrap(); - let openssh = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ13gEiGB test@sush"; - let login = Signed::new(response, actor(), signature(0, 0)); - let output = JobOutputState { stdout_len: 22, stderr_len: 0, @@ -478,10 +441,6 @@ mod wire_format { ), )), Message::Request(Request::job(actor(), JobRequest::Stop(long_job))), - Message::Request(Request::identity( - actor(), - IdentityRequest::Login(openssh.to_string(), login), - )), Message::Event( baseboard.clone(), Event::Version(VersionInfo { diff --git a/server/src/server.rs b/server/src/server.rs index c1fdee60..badab630 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -26,11 +26,11 @@ use sush_api::{ JobOutputParams, JobStartParams, JobStarted, JobStopParams, JobTargetParams, KeyIdParam, RoutingParam, SessionAndJobIds, SessionAndKeyIds, SessionIdParam, SessionStartBody, SushApi, }; -use sush_common::authn::Identity; +use sush_common::authn::{Authn, Identity}; use sush_common::jobs::{ JsonJobStatusMap, Session, SessionServerNonce, SignedJob, job_status_to_json_map, }; -use sush_common::keys::{SshPublicKey, pem_cert_chain}; +use sush_common::keys::{KeyId, SshPublicKey, pem_cert_chain}; use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; @@ -42,13 +42,13 @@ const MAX_WS_MESSAGE_SIZE: usize = 0x10_0000; pub struct ApiServer; -/// The request line as [`JobManager::iam`] wants it: the method and -/// the target exactly as received. -fn request_line(request: &dropshot::RequestInfo) -> (&str, &str) { - ( - request.method().as_str(), - request.uri().path_and_query().map_or("", |pq| pq.as_str()), - ) +/// The key ID claimed by a request in its `Authorization` header. +/// Only the proxy verifies these claims. +fn requester(authorization: Option) -> Result { + authorization + .and_then(|authorization| authorization.parse::().ok()) + .map(|authn| authn.key_id().clone()) + .ok_or(JobError::NoRequester) } impl SushApi for ApiServer { @@ -63,9 +63,7 @@ impl SushApi for ApiServer { ) -> Result, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let KeyIdParam { key_id } = params.into_inner(); let certs = mgr.cert_chain(&authn, &key_id)?; let chain = pem_cert_chain(certs).map_err(JobError::Key)?; @@ -75,30 +73,19 @@ impl SushApi for ApiServer { // Identity management. async fn iam( - ctx: RequestContext, - headers: Header, + _ctx: RequestContext, + _headers: Header, _query: QueryParams, - body: TypedBody>, + _body: TypedBody>, ) -> Result, HttpError> { - let mgr = ctx.context(); - let Authorization { authorization } = headers.into_inner(); - let public_key = body.into_inner(); - let identity = mgr - .iam(authorization, public_key, request_line(&ctx.request)) - .await?; - Ok(HttpResponseOk(identity)) + Err(JobError::ProxyOnly.into()) } async fn identities( - ctx: RequestContext, - headers: Header, + _ctx: RequestContext, + _headers: Header, ) -> Result>, HttpError> { - let mgr = ctx.context(); - let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; - Ok(HttpResponseOk(mgr.identities(&authn).await?)) + Err(JobError::ProxyOnly.into()) } // Session management. @@ -109,9 +96,7 @@ impl SushApi for ApiServer { ) -> Result, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; if let Some(session) = mgr.session(&authn) { Ok(HttpResponseOk(session)) } else { @@ -125,9 +110,7 @@ impl SushApi for ApiServer { ) -> Result, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let _authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let _authn = requester(authorization)?; Ok(HttpResponseOk(mgr.fresh_server_nonce())) } @@ -139,9 +122,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let SessionIdParam { session_id } = params.into_inner(); let SessionStartBody { client_nonce, @@ -160,9 +141,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let SessionIdParam { session_id } = params.into_inner(); mgr.session_stop(&authn, session_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -175,9 +154,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let SessionAndJobIds { session_id, job_id } = params.into_inner(); mgr.session_skip_job(&authn, session_id, job_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -191,9 +168,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let SessionAndKeyIds { session_id, key_id } = params.into_inner(); let AccessParam { access } = query.into_inner(); mgr.session_allow_attach(&authn, session_id, key_id, access) @@ -208,9 +183,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let SessionAndKeyIds { session_id, key_id } = params.into_inner(); mgr.session_deny_attach(&authn, session_id, key_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -227,9 +200,7 @@ impl SushApi for ApiServer { ) -> Result, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let JobTargetParams { job_id, target: _ } = params.into_inner(); let job = body.into_inner(); if *job.job_id() != job_id { @@ -251,9 +222,7 @@ impl SushApi for ApiServer { ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let JobIdParam { job_id } = params.into_inner(); mgr.job_stop(&authn, &job_id, query.into_inner()).await?; Ok(HttpResponseUpdatedNoContent()) @@ -267,9 +236,7 @@ impl SushApi for ApiServer { ) -> Result, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let JobIdParam { job_id } = params.into_inner(); Ok(HttpResponseOk(job_status_to_json_map( mgr.job_status(&authn, &job_id).await?, @@ -285,9 +252,7 @@ impl SushApi for ApiServer { let mgr = ctx.context(); let headers = headers.into_inner(); let range = headers.range()?; - let authn = mgr - .iam(headers.authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(headers.authorization)?; let JobOutputParams { job_id, stream, @@ -326,9 +291,7 @@ impl SushApi for ApiServer { ) -> WebsocketEndpointResult { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let JobTargetParams { job_id, target } = params.into_inner(); let target = if target == "*" { mgr.own_baseboard().clone() @@ -362,9 +325,7 @@ impl SushApi for ApiServer { ) -> Result>, HttpError> { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); - let authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; + let authn = requester(authorization)?; let JobHistoryParams { limit, offset } = query.into_inner(); Ok(HttpResponseOk( mgr.job_history(&authn, limit, offset) diff --git a/server/src/state.rs b/server/src/state.rs index c174dd08..eac417bf 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -36,7 +36,6 @@ use crate::certs::Certificates; use crate::executor::{Executor, PathIsolation}; use crate::gossip::{Seed, Universe}; use crate::history::JobHistory; -use crate::identities::Identities; use crate::job::SocketSender; use crate::locker::Locker; use crate::locker::bookmark::SushBookmark; @@ -297,8 +296,6 @@ pub struct State { attachments: AttachmentPoints, /// Job request signing certificates. certs: Certificates, - /// SSH identities for request attribution. - identities: Identities, /// This incarnation's relationship to its own past. past: Past, } @@ -316,7 +313,6 @@ impl State { pending_starts: LruCache::new(MAX_PENDING_STARTS), attachments: Default::default(), certs: Certificates::new(trust_anchors)?, - identities: Default::default(), past, }) } @@ -357,10 +353,6 @@ impl State { &self.certs } - pub fn identities(&self) -> &Identities { - &self.identities - } - /// Jobs running on our own baseboard, according to our history. pub fn own_running_jobs(&self) -> BTreeSet { self.history @@ -536,10 +528,6 @@ impl State { let actor = request.actor(); let log = log.new(o!("actor" => actor.to_string())); match request { - Request::Identity(r) => { - self.identities.apply(&log, r.request()); - Vec::new() - } Request::Session(r) => self.handle_session_request( &log, now, diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index 1032d21f..27948cb0 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -14,7 +14,6 @@ use std::time::Duration; use attest_mock::{MockData as _, MockLog, corim, corim::MockCorim, log}; use camino::Utf8PathBuf; -use chrono::Utc; use rand_core::{OsRng, RngCore as _}; use slog::{Drain as _, Logger, o}; use slog_term::{FullFormat, PlainSyncDecorator, TestStdoutWriter}; @@ -25,10 +24,9 @@ use sprockets_tls_test_utils::{ OutputFileExistsBehavior, alias_prefix, cert_path, certlist_path, generate_config, platform_id, private_key_path, root_prefix, sprockets_auth_prefix, }; -use sush_common::authn::{Challenge, ChallengeResponse, Identity, Nonce, RequestKey}; use sush_common::codephrases::Codephrase; use sush_common::jobs::{BaseboardId, JobId, JobMode, JobStartRequest, SessionId, SignedJob}; -use sush_common::keys::{EphemeralKey, KeyType, Signer as _}; +use sush_common::keys::{EphemeralKey, KeyId, KeyType, Signer as _}; use sush_common::targets::Target; use sush_server::gossip::GossipConfig; use sush_server::link::CorpusSource; @@ -147,15 +145,9 @@ pub fn ephemeral_root() -> EphemeralKey { .unwrap() } -/// An authenticated identity for `key`, as `iam` would produce. -pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { - let challenge = Challenge::new(Nonce::random()); - let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); - let signed = key.sign(response).await.unwrap(); - let verified = signed - .verify_with_ssh_public_key(&key.ssh_public_key()) - .unwrap(); - Identity::new(key.ssh_public_key(), verified, Utc::now()).unwrap() +/// Return the key ID to claim on a request from `key`. +pub fn requester(key: &EphemeralKey) -> KeyId { + key.ssh_public_key().key_id().unwrap() } /// Sign a batch job request with `root`. diff --git a/server/tests/distributed.rs b/server/tests/distributed.rs index c4231082..e9ae996d 100644 --- a/server/tests/distributed.rs +++ b/server/tests/distributed.rs @@ -23,13 +23,12 @@ use tokio_util::sync::CancellationToken; use chrono::Utc; use sush_api::{JobStartParams, JobStopParams, JobWait}; -use sush_common::authn::Identity; use sush_common::jobs::{ JobId, JobMode, JobOutputState, JobStartRequest, JobStatus, ProcessError, Session, SessionClientNonce, SessionId, SessionPreimage, SessionSeq, SessionSignerNonce, SessionSushNonce, SignedJob, SkipReason, }; -use sush_common::keys::{EphemeralKey, Signer as _, pem_cert_chain}; +use sush_common::keys::{EphemeralKey, KeyId, Signer as _, pem_cert_chain}; use sush_common::targets::{Cubbies, SledHealth, SledId, Target}; use sush_common::version::VersionInfo; use sush_server::executor::PathIsolation; @@ -42,7 +41,7 @@ use sush_server::state::GossipUniverse; use sush_server::{JobError, JobManager, seed_gossip}; use common::{ - baseboard, corpus, eventually, fake_identity, gossip_config, localhost, pki, sign_job, + baseboard, corpus, eventually, gossip_config, localhost, pki, requester, sign_job, sprockets_config, test_logger, }; @@ -163,8 +162,8 @@ async fn jobs_gossip_between_sleds() { // A session started on sled A becomes B's active session too, // once a signed job confirms it. - let authn_a = fake_identity(&mut root).await; - let authn_b = fake_identity(&mut root).await; + let authn_a = requester(&root); + let authn_b = requester(&root); let Ceremony { session_id, client_nonce, @@ -263,8 +262,8 @@ async fn first_job_confirms_start_on_another_sled() { .await; // A takes the start. - let authn_a = fake_identity(&mut root).await; - let authn_b = fake_identity(&mut root).await; + let authn_a = requester(&root); + let authn_b = requester(&root); let Ceremony { session_id, client_nonce, @@ -331,7 +330,7 @@ async fn cubby_resolves_on_accepting_sled() { .send(Cubbies::from([(14, b.baseboard.clone())])) .unwrap(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let Ceremony { session_id, client_nonce, @@ -404,7 +403,7 @@ async fn stop_waits_for_remote_sleds() { .await; sees(&a, &b).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let Ceremony { session_id, client_nonce, @@ -467,7 +466,7 @@ async fn unverified_job_is_refused() { let log = test_logger(function_name!()); let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let Ceremony { session_id, client_nonce, @@ -537,9 +536,9 @@ async fn forged_start_never_takes_effect() { let log = test_logger(function_name!()); let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn = fake_identity(&mut root).await; - let mut forger = common::ephemeral_root(); - let authn_forger = fake_identity(&mut forger).await; + let authn = requester(&root); + let forger = common::ephemeral_root(); + let authn_forger = requester(&forger); // A genuine session's first job confirms it. let Ceremony { @@ -680,8 +679,8 @@ async fn partitioned_sessions_converge_on_higher_seq() { let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; let b = Sled::start(&log, &dir, 2, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; - let authn_b = fake_identity(&mut root).await; + let authn_a = requester(&root); + let authn_b = requester(&root); a.peers.send(BTreeSet::from([b.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr])).unwrap(); eventually("universe convergence", 120, async || { @@ -797,7 +796,7 @@ async fn rejoining_replays_without_reexecuting() { // Sled A runs a whole job before B exists. let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let Ceremony { session_id, client_nonce, @@ -828,7 +827,7 @@ async fn rejoining_replays_without_reexecuting() { let b = Sled::start(&log, &dir, 2, &root_pem, &shutdown).await; a.peers.send(BTreeSet::from([b.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr])).unwrap(); - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); eventually("A's history replays on B", 120, async || { b.mgr.job_status(&authn_b, &job_id).await.is_ok_and(|map| { map.get(&a.baseboard) @@ -901,7 +900,7 @@ async fn rejoining_replays_without_reexecuting() { #[tokio::test] async fn interrupted_jobs_get_stopped() { let (_tmp, dir) = pki("sush-interrupted-", 2); - let mut root = common::ephemeral_root(); + let root = common::ephemeral_root(); let root_pem = dir.join("job-root.pem"); write( &root_pem, @@ -912,7 +911,7 @@ async fn interrupted_jobs_get_stopped() { let log = test_logger(function_name!()); let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); // A previous life of sled 2 started a job and died before stopping // it: the rack's history shows it running forever. @@ -1046,7 +1045,7 @@ async fn stragglers_do_not_interrupt_live_jobs() { }) .await; sees(&a, &b).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let Ceremony { session_id, client_nonce, @@ -1084,7 +1083,7 @@ async fn stragglers_do_not_interrupt_live_jobs() { a.peers.send(BTreeSet::from([b.addr, c.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr, c.addr])).unwrap(); c.peers.send(BTreeSet::from([a.addr, b.addr])).unwrap(); - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); eventually("the marooned message reaches B", 120, async || { b.mgr.versions().iter().any(|row| row.baseboard == marooned) }) @@ -1164,12 +1163,7 @@ async fn sign_job_for( /// Confirm the start pending on `sled` with the session's first job, /// and advance `session` past it. -async fn confirm_start( - sled: &Sled, - authn: &Identity, - root: &mut EphemeralKey, - session: &mut Session, -) { +async fn confirm_start(sled: &Sled, authn: &KeyId, root: &mut EphemeralKey, session: &mut Session) { let job_id = session.next_job_id(); let job = sign_job_for(root, job_id, session.session_id(), "true", &sled.baseboard).await; sled.mgr @@ -1203,7 +1197,7 @@ async fn lost_suffix_never_reruns() { // Sled A anchors the session and survives throughout. let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); // Sled B keeps its boundary in a locker. It joins before the // session starts: a record-less sled serves only sessions // that started after it arrived. @@ -1212,7 +1206,7 @@ async fn lost_suffix_never_reruns() { let b_shutdown = CancellationToken::new(); let locker = Locker::new(&log, baseboard(2), vec![slot.clone()]).unwrap(); let b = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &b_shutdown).await; - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); a.peers.send(BTreeSet::from([b.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr])).unwrap(); eventually("universe convergence", 120, async || { @@ -1288,7 +1282,7 @@ async fn lost_suffix_never_reruns() { sleep(Duration::from_millis(500)).await; let locker = Locker::new(&log, baseboard(2), vec![slot]).unwrap(); let b = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &shutdown).await; - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); a.peers.send(BTreeSet::from([b.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr])).unwrap(); @@ -1374,15 +1368,15 @@ async fn session_resumes_at_stored_successor() { // Three sleds converge, then the session starts, so everyone // serves it. B keeps its boundary in a locker. let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let c = Sled::start(&log, &dir, 3, &root_pem, &shutdown).await; - let authn_c = fake_identity(&mut root).await; + let authn_c = requester(&root); let boundary_dir = TempDir::with_prefix("sush-boundary-").unwrap(); let slot = Utf8PathBuf::from_path_buf(boundary_dir.path().to_path_buf()).unwrap(); let b_shutdown = CancellationToken::new(); let locker = Locker::new(&log, baseboard(2), vec![slot.clone()]).unwrap(); let b = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &b_shutdown).await; - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); a.peers.send(BTreeSet::from([b.addr, c.addr])).unwrap(); b.peers.send(BTreeSet::from([a.addr])).unwrap(); c.peers.send(BTreeSet::from([a.addr])).unwrap(); @@ -1469,7 +1463,7 @@ async fn session_resumes_at_stored_successor() { sleep(Duration::from_millis(500)).await; let locker = Locker::new(&log, baseboard(2), vec![slot]).unwrap(); let b = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &shutdown).await; - let authn_b = fake_identity(&mut root).await; + let authn_b = requester(&root); b.peers.send(BTreeSet::from([c.addr])).unwrap(); c.peers.send(BTreeSet::from([b.addr])).unwrap(); eventually("the session replays to B", 120, async || { @@ -1531,9 +1525,9 @@ async fn supersession_crosses_universes() { // Two universes that never meet: A anchors one session, D another. let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let d = Sled::start(&log, &dir, 3, &root_pem, &shutdown).await; - let authn_d = fake_identity(&mut root).await; + let authn_d = requester(&root); let Ceremony { session_id: session2_id, client_nonce, @@ -1553,7 +1547,7 @@ async fn supersession_crosses_universes() { let x_shutdown = CancellationToken::new(); let locker = Locker::new(&log, baseboard(2), vec![slot.clone()]).unwrap(); let x = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &x_shutdown).await; - let authn_x = fake_identity(&mut root).await; + let authn_x = requester(&root); a.peers.send(BTreeSet::from([x.addr])).unwrap(); x.peers.send(BTreeSet::from([a.addr])).unwrap(); eventually("universe convergence", 120, async || { @@ -1612,7 +1606,7 @@ async fn supersession_crosses_universes() { let x_shutdown = CancellationToken::new(); let locker = Locker::new(&log, baseboard(2), vec![slot.clone()]).unwrap(); let x = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &x_shutdown).await; - let authn_x = fake_identity(&mut root).await; + let authn_x = requester(&root); d.peers.send(BTreeSet::from([x.addr])).unwrap(); x.peers.send(BTreeSet::from([d.addr])).unwrap(); eventually("session two gossips to X", 120, async || { @@ -1656,7 +1650,7 @@ async fn supersession_crosses_universes() { let x_shutdown = CancellationToken::new(); let locker = Locker::new(&log, baseboard(2), vec![slot.clone()]).unwrap(); let x = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &x_shutdown).await; - let authn_x = fake_identity(&mut root).await; + let authn_x = requester(&root); a.peers.send(BTreeSet::from([x.addr])).unwrap(); x.peers.send(BTreeSet::from([a.addr])).unwrap(); eventually("session one replays to X", 120, async || { @@ -1736,7 +1730,7 @@ async fn supersession_crosses_universes() { sleep(Duration::from_millis(500)).await; let locker = Locker::new(&log, baseboard(2), vec![slot]).unwrap(); let x = Sled::start_with_locker(&log, &dir, 2, &root_pem, locker, &shutdown).await; - let authn_x = fake_identity(&mut root).await; + let authn_x = requester(&root); d.peers.send(BTreeSet::from([x.addr])).unwrap(); x.peers.send(BTreeSet::from([d.addr])).unwrap(); eventually("session two replays to X", 120, async || { @@ -1785,7 +1779,7 @@ async fn witnessed_session_survives_restart() { let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let boundary_dir = TempDir::with_prefix("sush-boundary-").unwrap(); let slot = Utf8PathBuf::from_path_buf(boundary_dir.path().to_path_buf()).unwrap(); @@ -1865,7 +1859,7 @@ async fn witnessed_session_survives_restart() { #[tokio::test] async fn bookmarks_survive_restart() { let (_tmp, dir) = pki("sush-bookmark-", 2); - let mut root = common::ephemeral_root(); + let root = common::ephemeral_root(); let root_pem = dir.join("job-root.pem"); write( &root_pem, @@ -1878,7 +1872,7 @@ async fn bookmarks_survive_restart() { // Sled A holds session history, so it wins every dominance contest. let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); let Ceremony { session_id, client_nonce, @@ -1962,7 +1956,7 @@ async fn gossip_survives_bookmark_failure() { let shutdown = CancellationToken::new(); let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; - let authn_a = fake_identity(&mut root).await; + let authn_a = requester(&root); // Sled 2's bookmark points into a directory that does not exist. // It sheds the bookmark and gossips anyway, stranding identities diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin deleted file mode 100644 index 66dd1028..00000000 Binary files a/server/tests/output/identity-login-request.bin and /dev/null differ diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 743b42fb..0759a94f 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -20,6 +20,7 @@ libc.workspace = true pki-playground.workspace = true pwd.workspace = true rand_core.workspace = true +reqwest.workspace = true rumors.workspace = true serde_json.workspace = true sha3.workspace = true @@ -31,6 +32,7 @@ sprockets-tls-test-utils.workspace = true sush-api = { path = "../api" } sush-client = { path = "../client" } sush-common = { path = "../common" } +sush-proxy = { path = "../proxy" } sush-server = { path = "../server", features = ["test-support"] } tempfile.workspace = true tokio.workspace = true diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index 6f404f8c..21b438a3 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -35,6 +35,7 @@ use sush_api::sush_api_mod::api_description; use sush_client::tls::client as tls_client; use sush_client::tunnel::{Tunnel, pipe}; use sush_client::{AuthzSigner, Client, Error as ClientError}; +use sush_common::authn::BoundCredentials; use sush_common::hash::hash; use sush_common::interactive::{InteractiveJobControl, InteractiveJobMessage}; use sush_common::jobs::{ @@ -42,12 +43,12 @@ use sush_common::jobs::{ job_status_try_from_json_map, }; use sush_common::targets::Cubbies; -use sush_server::proxy::{Targets, platform_tls}; -use sush_server::{ApiServer, ProxyServer}; +use sush_proxy::{ProxyServer, Targets, platform_tls}; +use sush_server::ApiServer; use crate::test_utils::{ SignJobRequest as _, authz, ephemeral_test_root, http_session_ceremony, manager_and_test_root, - test_baseboard_id, test_logger, test_pki, vouched_proxy_pems, + requester, test_baseboard_id, test_logger, test_pki, vouched_proxy_pems, }; const TIMEOUT: Duration = Duration::from_secs(10); @@ -71,25 +72,21 @@ async fn client_server() { .start() .expect("failed to start server"); - // Connect and authenticate to the server. + // With no proxy in front, a request must claim a key, and logins + // are not checked here. let addr = server.local_addr(); let signer = AuthzSigner::default(); let client = Client::new(&format!("http://{addr}"), signer.clone()); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(nameless) = client.session().send().await.unwrap_err() else { + panic!("expected error response") + }; + assert_eq!(nameless.status(), 400, "expected 400 Bad Request"); + signer.set_claim(requester(&root)); + let ClientError::ErrorResponse(login) = client.iam().body(None).send().await.unwrap_err() else { panic!("expected error response") }; - assert_eq!(unauthz.status(), 401, "expected 401 Unauthorized"); - let (identity, credentials) = authz(&client, unauthz, &mut root).await; - signer.set(Some(credentials)); - let iam = client - .iam() - .body(None) - .send() - .await - .expect("can't authenticate") - .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(login.status(), 404, "expected 404 Not Found"); // Start a session and run a job. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -196,6 +193,28 @@ async fn client_proxy_server() { .into_inner(); assert_eq!(iam, identity, "who am I?"); + // Claiming a key is not a login at the proxy. + let claim = AuthzSigner::default(); + claim.set_claim(requester(&root)); + let claimant = Client::new(&format!("http://{proxy_addr}"), claim); + let ClientError::ErrorResponse(claimed) = claimant.session().send().await.unwrap_err() else { + panic!("expected error response") + }; + assert_eq!(claimed.status(), 401, "expected 401 Unauthorized"); + + // A second `Authorization` header could smuggle an unchecked claim + // past the proxy, so a request may carry only one. + let signed = signer.get().unwrap().sign("GET", "/sessions"); + let smuggled = BoundCredentials::claim(requester(&ephemeral_test_root())).to_string(); + let response = reqwest::Client::new() + .get(format!("http://{proxy_addr}/sessions")) + .header("authorization", signed) + .header("authorization", smuggled) + .send() + .await + .expect("can't send request"); + assert_eq!(response.status(), 400, "expected 400 Bad Request"); + // Attach to an interactive job through the proxy, routed by the // target path segment, and echo bytes over the bridged upgrade. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -284,9 +303,15 @@ async fn client_proxy_server() { .into_inner(); assert_eq!(resolved, test_baseboard_id()); - // With no sleds in the table, everything is refused. + // With no sleds in the table, everything but logins is refused. tx_targets.send_replace(BTreeMap::new()); - let unavailable = client.iam().body(None).send().await.unwrap_err(); + client + .iam() + .body(None) + .send() + .await + .expect("the proxy answers logins itself"); + let unavailable = client.session().send().await.unwrap_err(); if let ClientError::UnexpectedResponse(response) = unavailable { assert_eq!(response.status(), 503); } @@ -345,25 +370,11 @@ async fn interactive_job() { .start() .expect("failed to start server"); - // Connect and authenticate to the server. + // Connect to the server. let addr = server.local_addr(); let signer = AuthzSigner::default(); + signer.set_claim(requester(&root)); let client = Client::new(&format!("http://{addr}"), signer.clone()); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() - else { - panic!("expected error response") - }; - assert_eq!(unauthz.status(), 401, "expected 401 Unauthorized"); - let (identity, credentials) = authz(&client, unauthz, &mut root).await; - signer.set(Some(credentials)); - let iam = client - .iam() - .body(None) - .send() - .await - .expect("can't authenticate") - .into_inner(); - assert_eq!(iam, identity, "who am I?"); // Start a session and run an interactive job. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -463,18 +474,12 @@ async fn interactive_job() { assert_eq!(next_data_message(&mut stream1).await, &again); assert_eq!(next_data_message(&mut stream2).await, &again); - // A guest key authenticates, on its own client, but cannot attach - // until granted. - let mut guest_key = ephemeral_test_root(); + // A guest key, on its own client, cannot attach until granted. + let guest_key = ephemeral_test_root(); + let guest = requester(&guest_key); let guest_signer = AuthzSigner::default(); + guest_signer.set_claim(guest.clone()); let guest_client = Client::new(&format!("http://{addr}"), guest_signer.clone()); - let ClientError::ErrorResponse(unauthz) = - guest_client.iam().body(None).send().await.unwrap_err() - else { - panic!("expected error response") - }; - let (guest, guest_authz) = authz(&guest_client, unauthz, &mut guest_key).await; - guest_signer.set(Some(guest_authz)); let guest_attach = async || { guest_client .job_attach() @@ -493,7 +498,7 @@ async fn interactive_job() { client .session_allow_attach() .session_id(session.session_id()) - .key_id(guest.key_id.clone()) + .key_id(guest.clone()) .access(Access::ReadOnly) .send() .await @@ -541,7 +546,7 @@ async fn interactive_job() { client .session_deny_attach() .session_id(session.session_id()) - .key_id(guest.key_id.clone()) + .key_id(guest.clone()) .send() .await .expect("can't deny attach"); @@ -558,7 +563,7 @@ async fn interactive_job() { client .session_allow_attach() .session_id(session.session_id()) - .key_id(guest.key_id.clone()) + .key_id(guest.clone()) .access(Access::ReadWrite) .send() .await @@ -647,16 +652,11 @@ async fn streaming_job() { .start() .expect("failed to start server"); - // Connect and authenticate to the server. + // Connect to the server. let addr = server.local_addr(); let signer = AuthzSigner::default(); + signer.set_claim(requester(&root)); let client = Client::new(&format!("http://{addr}"), signer.clone()); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() - else { - panic!("expected error response") - }; - let (_identity, credentials) = authz(&client, unauthz, &mut root).await; - signer.set(Some(credentials)); // Start a streaming job too big for its pipe, so it blocks until we attach. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -791,16 +791,11 @@ async fn streaming_job_linger() { .start() .expect("failed to start server"); - // Connect and authenticate to the server. + // Connect to the server. let addr = server.local_addr(); let signer = AuthzSigner::default(); + signer.set_claim(requester(&root)); let client = Client::new(&format!("http://{addr}"), signer.clone()); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() - else { - panic!("expected error response") - }; - let (_identity, credentials) = authz(&client, unauthz, &mut root).await; - signer.set(Some(credentials)); // Run a streaming job small enough to finish before anyone attaches. let (session_id, body) = http_session_ceremony(&client, 1).await; diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 10e52924..62ae1990 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -24,26 +24,24 @@ use tokio_util::sync::CancellationToken; use x509_cert::time::Validity; use sush_api::{JobStartParams, JobStopParams, JobWait}; -use sush_client::context::Authz; -use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; use sush_common::jobs::{ Access, JobId, JobLimits, JobMode, JobOutputState, JobOutputStream::*, JobStartRequest, JobStatus, ProcessError, Session, SessionId, SessionPreimage, SessionSeq, SessionSignerNonce, SignedJob, SkipReason, }; -use sush_common::keys::{EphemeralKey, KeyError, KeyType, Signer as _, pem_cert_chain}; +use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _, pem_cert_chain}; use sush_common::targets::{Cubbies, Target}; use sush_server::gossip::{LinkedBaseboards, Universe}; use sush_server::io::BATCH_OUTPUT_BUFFER_SIZE; use sush_server::locker::Locker; -use sush_server::messages::v0::{IdentityRequest, Message, Request, SessionRequest}; +use sush_server::messages::v0::{Message, Request, SessionRequest}; use sush_server::output::{JobOutputDir, OutputDirs}; use sush_server::{JobError, JobManager}; use crate::test_utils::{ IntoBytes as _, SessionCeremony, SignJobRequest as _, confirm_start, ephemeral_test_root, - ephemeral_test_subject, fake_identity, manager_and_test_root, manager_test_root_and_peer, - no_cubbies, null_gossip, session_ceremony, test_baseboard_id, test_logger, + ephemeral_test_subject, manager_and_test_root, manager_test_root_and_peer, no_cubbies, + null_gossip, requester, session_ceremony, test_baseboard_id, test_logger, }; use sush_server::executor::PathIsolation; @@ -102,7 +100,7 @@ async fn jobs() { let log = test_logger(function_name!()); let (mgr, mut root, dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -278,7 +276,7 @@ async fn jobs() { async fn job_contract() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -349,7 +347,7 @@ async fn job_contract() { async fn job_stop_reaches_contract() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -422,7 +420,7 @@ async fn job_stop_reaches_contract() { async fn job_limits_and_environment() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -479,7 +477,7 @@ async fn job_limits_and_environment() { async fn interactive_job_session() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -533,7 +531,7 @@ async fn interactive_job_session() { async fn job_session_enforced() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); // With no active session, submission fails at the front door. let ghost = Session::new(SessionId::random()); @@ -571,8 +569,8 @@ async fn job_session_enforced() { #[tokio::test] async fn fetch_rotates_server_nonce() { let log = test_logger(function_name!()); - let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let (mgr, root, _dir, _shutdown) = manager_and_test_root(log).await; + let authn = requester(&root); let ceremony = session_ceremony(&mgr, 1); let bogus = SessionId::random(); @@ -645,7 +643,7 @@ async fn job_stop() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -750,7 +748,7 @@ async fn job_stop() { async fn queue_job_behind_hole( mgr: &JobManager, root: &mut EphemeralKey, - authn: &Identity, + authn: &KeyId, session: &mut Session, ) -> JobId { let session_id = session.session_id(); @@ -776,7 +774,7 @@ async fn queue_job_behind_hole( async fn cancel_queued_job() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -842,7 +840,7 @@ async fn cancel_queued_job() { .expect("should be able to stop job A"); } -async fn wait_for_session_ended_skip(mgr: &JobManager, authn: &Identity, job_id: &JobId) { +async fn wait_for_session_ended_skip(mgr: &JobManager, authn: &KeyId, job_id: &JobId) { timeout(Duration::from_secs(30), async { loop { if let Ok(map) = mgr.job_status(authn, job_id).await @@ -868,7 +866,7 @@ async fn wait_for_session_ended_skip(mgr: &JobManager, authn: &Identity, job_id: async fn session_stop_skips_queued_jobs() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -922,7 +920,7 @@ async fn session_stop_skips_queued_jobs() { async fn superseding_session_skips_queued_jobs() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -965,7 +963,7 @@ async fn job_output_perms() { let (mgr, mut root, dir, _shutdown) = manager_and_test_root(log).await; let dir_perms = metadata(&dir).await.unwrap().permissions(); let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1038,7 +1036,7 @@ async fn cubby_targets() { ) .await .unwrap(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1117,7 +1115,7 @@ async fn root_certs_from_files() { .unwrap(); // A job signed by the configured root runs. - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1204,7 +1202,7 @@ async fn job_output_dir_moves() { .await .unwrap(); let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1309,14 +1307,9 @@ async fn universe_swap() { ) .await .unwrap(); - let authn = fake_identity(&mut root).await; - - async fn run_job( - mgr: &JobManager, - root: &mut EphemeralKey, - authn: &Identity, - seq: u64, - ) -> JobId { + let authn = requester(&root); + + async fn run_job(mgr: &JobManager, root: &mut EphemeralKey, authn: &KeyId, seq: u64) -> JobId { let SessionCeremony { session_id, client_nonce, @@ -1396,7 +1389,7 @@ async fn universe_swap() { async fn shutdown() { let log = test_logger(function_name!()); let (mut mgr, mut root, _dir, shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1448,8 +1441,8 @@ async fn shutdown() { #[tokio::test] async fn cert_chain() { let log = test_logger(function_name!()); - let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let (mgr, root, _dir, _shutdown) = manager_and_test_root(log).await; + let authn = requester(&root); assert_eq!( mgr.cert_chain(&authn, root.key_id()).unwrap(), vec![root.cert().clone()] @@ -1473,7 +1466,7 @@ async fn cert_chain() { async fn attribution() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1505,10 +1498,7 @@ async fn attribution() { .await .unwrap(); session.job_started(job_a.into_signed()); - assert_eq!( - mgr.session(&authn).unwrap().started_by(), - Some(&authn.key_id) - ); + assert_eq!(mgr.session(&authn).unwrap().started_by(), Some(&authn)); let hole_id = session.next_job_id(); let hole = root .sign_job_request(hole_id, session_id, "true", false) @@ -1529,7 +1519,7 @@ async fn attribution() { mgr.wait_for_job_status(&job_id_b).await.unwrap(); let status = mgr.job_status(&authn, &job_id_b).await.unwrap()[mgr.own_baseboard()].clone(); assert!( - matches!(&status, JobStatus::Queued { actor, .. } if *actor == authn.key_id), + matches!(&status, JobStatus::Queued { actor, .. } if *actor == authn), "expected job B queued by us, got {status:?}" ); @@ -1546,7 +1536,7 @@ async fn attribution() { .unwrap(); let status = mgr.job_status(&authn, &job_id_b).await.unwrap()[mgr.own_baseboard()].clone(); assert!( - matches!(&status, JobStatus::Cancelled { actor, .. } if *actor == authn.key_id), + matches!(&status, JobStatus::Cancelled { actor, .. } if *actor == authn), "expected job B cancelled by us, got {status:?}" ); mgr.job_stop( @@ -1569,7 +1559,7 @@ async fn attribution() { async fn job_targets() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -1623,77 +1613,6 @@ async fn job_targets() { .unwrap(); } -/// A login gossips as evidence every sled verifies for itself: real -/// evidence authorizes bound requests with no local login, and -/// fabricated evidence registers nothing. -#[named] -#[tokio::test] -async fn gossiped_identities() { - let log = test_logger(function_name!()); - let (mgr, mut root, peer, _dir, _shutdown) = manager_test_root_and_peer(log).await; - let mut liar = ephemeral_test_root(); - let root_pk = root.ssh_public_key(); - let root_key_id = root_pk.key_id().unwrap(); - - // A liar claims root's key with evidence signed by their own. - let bogus_key = RequestKey::new(); - let bogus = ChallengeResponse::new(Challenge::new(Nonce::random()), bogus_key.verifier()); - let signed_by_liar = liar.sign(bogus).await.unwrap(); - let bogus_verified = signed_by_liar - .clone() - .verify_with_ssh_public_key(&liar.ssh_public_key()) - .unwrap(); - let mut bogus_credentials = Credentials::new(bogus_verified); - bogus_credentials.key_id = root_key_id.clone(); - let bogus_authz = Authz::new(bogus_credentials, bogus_key); - peer.send( - Message::Request(Request::identity( - root_key_id.clone(), - IdentityRequest::Login(root_pk.to_openssh().unwrap(), signed_by_liar), - )) - .into(), - ) - .unwrap(); - - // Real evidence authorizes here without ever logging in here. - let request_key = RequestKey::new(); - let response = ChallengeResponse::new(Challenge::new(Nonce::random()), request_key.verifier()); - let signed = root.sign(response).await.unwrap(); - let verified = signed.clone().verify_with_ssh_public_key(&root_pk).unwrap(); - let mut credentials = Credentials::new(verified); - credentials.key_id = root_key_id.clone(); - peer.send( - Message::Request(Request::identity( - root_key_id.clone(), - IdentityRequest::Login(root_pk.to_openssh().unwrap(), signed), - )) - .into(), - ) - .unwrap(); - let authz = Authz::new(credentials, request_key); - let authn = timeout(Duration::from_secs(30), async { - loop { - let header = authz.header("GET", "/sessions"); - match mgr.iam(Some(header), None, ("GET", "/sessions")).await { - Ok(authn) => break authn, - Err(_) => sleep(Duration::from_millis(50)).await, - } - } - }) - .await - .expect("gossiped login never registered"); - assert_eq!(authn.key_id, root_key_id); - - // The fabricated login was processed before the real one on the - // same handle, and registered nothing. - let header = bogus_authz.header("GET", "/sessions"); - assert!( - mgr.iam(Some(header), None, ("GET", "/sessions")) - .await - .is_err() - ); -} - /// Attach access: strangers are refused, the starter grants and /// withdraws guest access mid-session, only the starter may grant, and /// grants from non-starters over gossip are ignored. @@ -1702,11 +1621,11 @@ async fn gossiped_identities() { async fn attach_grants() { let log = test_logger(function_name!()); let (mgr, mut root, peer, _dir, _shutdown) = manager_test_root_and_peer(log).await; - let owner = fake_identity(&mut root).await; + let owner = requester(&root); let validity = Validity::from_now(Duration::from_secs(60)).unwrap(); - let mut guest_key = + let guest_key = EphemeralKey::new_root(KeyType::P256, ephemeral_test_subject(), validity).unwrap(); - let guest = fake_identity(&mut guest_key).await; + let guest = requester(&guest_key); let SessionCeremony { session_id, @@ -1737,7 +1656,7 @@ async fn attach_grants() { // The starter attaches read-write; a stranger not at all; a guest // may not grant. let target = mgr.own_baseboard().clone(); - let attach = async |authn: Identity| mgr.job_attachment(&authn, &job_id, &target).await; + let attach = async |authn: KeyId| mgr.job_attachment(&authn, &job_id, &target).await; assert!(matches!( attach(owner.clone()).await, Ok((_, Access::ReadWrite)) @@ -1747,14 +1666,14 @@ async fn attach_grants() { Err(JobError::AttachDenied) )); assert!(matches!( - mgr.session_allow_attach(&guest, session_id, owner.key_id.clone(), Access::ReadOnly) + mgr.session_allow_attach(&guest, session_id, owner.clone(), Access::ReadOnly) .await, Err(JobError::NotSessionStarter) )); // The starter grants read-only, upgrades to read-write, then // withdraws, all mid-session. - let granted = async |authn: Identity, want: Option| { + let granted = async |authn: KeyId, want: Option| { timeout(Duration::from_secs(30), async { loop { let access = match attach(authn.clone()).await { @@ -1771,15 +1690,15 @@ async fn attach_grants() { .await .expect("grant never took effect") }; - mgr.session_allow_attach(&owner, session_id, guest.key_id.clone(), Access::ReadOnly) + mgr.session_allow_attach(&owner, session_id, guest.clone(), Access::ReadOnly) .await .unwrap(); granted(guest.clone(), Some(Access::ReadOnly)).await; - mgr.session_allow_attach(&owner, session_id, guest.key_id.clone(), Access::ReadWrite) + mgr.session_allow_attach(&owner, session_id, guest.clone(), Access::ReadWrite) .await .unwrap(); granted(guest.clone(), Some(Access::ReadWrite)).await; - mgr.session_deny_attach(&owner, session_id, guest.key_id.clone()) + mgr.session_deny_attach(&owner, session_id, guest.clone()) .await .unwrap(); granted(guest.clone(), None).await; @@ -1787,21 +1706,21 @@ async fn attach_grants() { // A grant whose actor is not the starter is ignored. The starter's // grant of a third key, sent after it on the same handle, marks it // processed. - let mut third_key = + let third_key = EphemeralKey::new_root(KeyType::P256, ephemeral_test_subject(), validity).unwrap(); - let third = fake_identity(&mut third_key).await; + let third = requester(&third_key); peer.send( Message::Request(Request::session( - guest.key_id.clone(), - SessionRequest::AllowAttach(session_id, guest.key_id.clone(), Access::ReadWrite), + guest.clone(), + SessionRequest::AllowAttach(session_id, guest.clone(), Access::ReadWrite), )) .into(), ) .unwrap(); peer.send( Message::Request(Request::session( - owner.key_id.clone(), - SessionRequest::AllowAttach(session_id, third.key_id.clone(), Access::ReadOnly), + owner.clone(), + SessionRequest::AllowAttach(session_id, third.clone(), Access::ReadOnly), )) .into(), ) @@ -1832,11 +1751,11 @@ async fn attach_grants() { async fn skip_and_stop_are_starter_only() { let log = test_logger(function_name!()); let (mgr, mut root, peer, _dir, _shutdown) = manager_test_root_and_peer(log).await; - let owner = fake_identity(&mut root).await; + let owner = requester(&root); let validity = Validity::from_now(Duration::from_secs(60)).unwrap(); - let mut guest_key = + let guest_key = EphemeralKey::new_root(KeyType::P256, ephemeral_test_subject(), validity).unwrap(); - let guest = fake_identity(&mut guest_key).await; + let guest = requester(&guest_key); let SessionCeremony { session_id, @@ -1882,13 +1801,12 @@ async fn skip_and_stop_are_starter_only() { Err(JobError::SessionNotCurrent(_)) )); assert!(matches!( - mgr.session_allow_attach(&owner, stale, guest.key_id.clone(), Access::ReadOnly) + mgr.session_allow_attach(&owner, stale, guest.clone(), Access::ReadOnly) .await, Err(JobError::SessionNotCurrent(_)) )); assert!(matches!( - mgr.session_deny_attach(&owner, stale, guest.key_id.clone()) - .await, + mgr.session_deny_attach(&owner, stale, guest.clone()).await, Err(JobError::SessionNotCurrent(_)) )); @@ -1898,7 +1816,7 @@ async fn skip_and_stop_are_starter_only() { // stop, and the cancellation actor proves whose skip applied. peer.send( Message::Request(Request::session( - guest.key_id.clone(), + guest.clone(), SessionRequest::Stop(session_id), )) .into(), @@ -1906,7 +1824,7 @@ async fn skip_and_stop_are_starter_only() { .unwrap(); peer.send( Message::Request(Request::session( - guest.key_id.clone(), + guest.clone(), SessionRequest::Skip(session_id, job_id), )) .into(), @@ -1914,7 +1832,7 @@ async fn skip_and_stop_are_starter_only() { .unwrap(); peer.send( Message::Request(Request::session( - owner.key_id.clone(), + owner.clone(), SessionRequest::Skip(session_id, job_id), )) .into(), @@ -1935,7 +1853,7 @@ async fn skip_and_stop_are_starter_only() { let status = mgr.job_status(&owner, &job_id).await.unwrap()[mgr.own_baseboard()].clone(); assert!(matches!( status, - JobStatus::Cancelled { actor, .. } if actor == owner.key_id + JobStatus::Cancelled { actor, .. } if actor == owner )); assert!(matches!( @@ -1954,7 +1872,7 @@ async fn skip_and_stop_are_starter_only() { async fn skip_racing_start_converges() { let log = test_logger(function_name!()); let (mgr, mut root, peer, _dir, _shutdown) = manager_test_root_and_peer(log).await; - let owner = fake_identity(&mut root).await; + let owner = requester(&root); let SessionCeremony { session_id, @@ -1989,7 +1907,7 @@ async fn skip_racing_start_converges() { peer.send( Message::Request(Request::session( - owner.key_id.clone(), + owner.clone(), SessionRequest::Skip(session_id, job_id), )) .into(), @@ -2018,7 +1936,7 @@ async fn skip_racing_start_converges() { async fn too_much_cpu() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2075,7 +1993,7 @@ async fn too_much_cpu() { async fn too_much_output() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2177,7 +2095,7 @@ async fn output_ranges() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2387,83 +2305,6 @@ async fn output_ranges() { } } -#[named] -#[tokio::test] -async fn iam() { - let log = test_logger(function_name!()); - let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let JobError::Unauthorized(nonce) = mgr.iam(None, None, ("POST", "/iam")).await.unwrap_err() - else { - panic!("should not be authorized yet"); - }; - - // Construct credentials. - let challenge = Challenge::new(nonce.clone()); - let request_key = RequestKey::new(); - let response = ChallengeResponse::new(challenge, request_key.verifier()); - let signed = root.sign(response).await.unwrap(); - let verified = signed.verify_with_cert(root.cert()).unwrap(); - let mut credentials = Credentials::new(verified); - let public_key = root.ssh_public_key(); - let key_id = public_key.key_id().unwrap(); - credentials.key_id = key_id.clone(); // override cert key ID - - // Register our identity. Initial credentials work only at `iam`. - assert!( - mgr.iam( - Some(credentials.to_string()), - Some(public_key.clone()), - ("GET", "/sessions"), - ) - .await - .is_err() - ); - let identity = mgr - .iam( - Some(credentials.to_string()), - Some(public_key.clone()), - ("POST", "/iam"), - ) - .await - .unwrap(); - let Identity { - key_id: iam_key_id, - public_key: iam_public_key, - nonce: iam_nonce, - time_authenticated: iam_authenticated, - } = identity.clone(); - assert_eq!(iam_key_id, key_id); - assert_eq!(iam_public_key, public_key); - assert_eq!(iam_nonce, credentials.nonce); - assert!(iam_authenticated <= Utc::now()); - - // A bound request authorizes; replaying its header does not, nor - // does presenting it against a different request line. - let authz = Authz::new(credentials.clone(), request_key); - let header = authz.header("GET", "/sessions"); - assert_eq!( - mgr.iam(Some(header.clone()), None, ("GET", "/sessions")) - .await - .unwrap() - .key_id, - key_id, - ); - assert!( - mgr.iam(Some(header), None, ("GET", "/sessions")) - .await - .is_err() - ); - let header = authz.header("GET", "/sessions"); - assert!(mgr.iam(Some(header), None, ("GET", "/jobs")).await.is_err()); - - // Replayed initial credentials are rejected: the nonce is spent. - assert!( - mgr.iam(Some(credentials.to_string()), None, ("POST", "/iam")) - .await - .is_err() - ); -} - /// Jobs must not inherit ignored signal dispositions. The embedding /// process may ignore SIGINT, and a shell cannot trap a signal that /// was ignored at entry, which turns ^C into a no-op in every job. @@ -2481,7 +2322,7 @@ async fn job_signal_dispositions() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2530,7 +2371,7 @@ async fn job_signal_dispositions() { async fn interactive_target_required() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2560,7 +2401,7 @@ async fn interactive_target_required() { async fn job_json() { let log = test_logger(function_name!()); let (mgr, mut root, dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, @@ -2603,7 +2444,7 @@ async fn job_json() { async fn streaming_validation() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; - let authn = fake_identity(&mut root).await; + let authn = requester(&root); let SessionCeremony { session_id, client_nonce, diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 4251caee..dc140b72 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -11,7 +11,6 @@ use std::time::Duration; use bytes::Bytes; use camino::{Utf8Path, Utf8PathBuf}; -use chrono::Utc; use ed25519_dalek::pkcs8::DecodePrivateKey as _; use ed25519_dalek::{Signer as _, SigningKey}; use futures::TryStreamExt as _; @@ -31,13 +30,13 @@ use x509_cert::time::Validity; use sush_api::{JobStartParams, JobWait}; use sush_client::context::Authz; use sush_client::{Client, ResponseValue}; -use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; +use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, RequestKey}; use sush_common::codephrases::Codephrase; use sush_common::jobs::{ JobId, JobMode, JobStartRequest, Session, SessionClientNonce, SessionId, SessionPreimage, SessionSeq, SessionSignerNonce, SessionSushNonce, VerifiedJob, }; -use sush_common::keys::{EphemeralKey, KeyType, Signer, pem_cert_chain}; +use sush_common::keys::{EphemeralKey, KeyId, KeyType, Signer, pem_cert_chain}; use sush_common::targets::{Cubbies, Target}; use sush_server::executor::PathIsolation; use sush_server::gossip::{LinkedBaseboards, Universe}; @@ -225,15 +224,9 @@ pub fn crafted_proxy_pems( (key_path, chain_path) } -pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { - let nonce = Nonce::random(); - let challenge = Challenge::new(nonce.clone()); - let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); - let signed = key.sign(response).await.unwrap(); - let verified = signed - .verify_with_ssh_public_key(&key.ssh_public_key()) - .unwrap(); - Identity::new(key.ssh_public_key(), verified, Utc::now()).unwrap() +/// Return the key ID to claim on a request from `key`. +pub fn requester(key: &EphemeralKey) -> KeyId { + key.ssh_public_key().key_id().unwrap() } pub async fn manager_and_test_root( @@ -279,7 +272,7 @@ pub fn session_ceremony(mgr: &JobManager, seq: u64) -> SessionCeremony { /// and advance `session` past it. pub async fn confirm_start( mgr: &JobManager, - authn: &Identity, + authn: &KeyId, root: &mut EphemeralKey, session: &mut Session, ) { diff --git a/tests/src/tls_tests.rs b/tests/src/tls_tests.rs index 647cd583..0ad28599 100644 --- a/tests/src/tls_tests.rs +++ b/tests/src/tls_tests.rs @@ -31,8 +31,8 @@ use sush_client::tls::client as tls_client; use sush_client::{AuthzSigner, Client, Error as ClientError}; use sush_common::keys::EphemeralKey; use sush_common::targets::Cubbies; -use sush_server::proxy::{Sleds, Targets, platform_tls}; -use sush_server::{ApiServer, JobManager, ProxyServer}; +use sush_proxy::{ProxyServer, Sleds, Targets, platform_tls}; +use sush_server::{ApiServer, JobManager}; use crate::test_utils::{ authz, crafted_proxy_pems, manager_and_test_root, test_baseboard_id, test_logger, test_pki,