diff --git a/Cargo.lock b/Cargo.lock index 1568fa84..6ec3d28d 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)", @@ -4835,7 +4835,6 @@ dependencies = [ "anyhow", "async-recursion", "atomicwrites", - "attest-data", "base64 0.22.1", "bytes", "bytesize", @@ -4885,6 +4884,7 @@ dependencies = [ name = "sush-common" version = "0.1.0" dependencies = [ + "attest-data", "bytes", "bytesize", "chrono", @@ -4929,6 +4929,7 @@ dependencies = [ "chrono", "ciborium", "clap", + "dice-mfg-msgs", "dropshot", "ed25519-dalek", "function_name", diff --git a/Cargo.toml b/Cargo.toml index 95187ad7..50bb0922 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" clap = { version = "4", features = ["derive", "env", "wrap_help"] } crypto-bigint = "0.5" +dice-mfg-msgs = { git = "https://github.com/oxidecomputer/dice-util", rev = "4a39ef08d81e5177edee0bddb1146032aa21074d", features = ["std"] } dropshot = "0.17" ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } function_name = "0.3" @@ -64,8 +65,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/api/src/lib.rs b/api/src/lib.rs index 93fb7a70..c35e0aa8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize}; use sled_hardware_types::BaseboardId; use strum::{Display, EnumString}; -use sush_common::authn::Identity; +use sush_common::authn::{Identity, LoginReceipt}; use sush_common::jobs::{ Access, JobId, JobLimits, JobOutputStream, JobStatus, JsonJobStatusMap, Session, SessionClientNonce, SessionId, SessionSeq, SessionServerNonce, SessionSignerNonce, SignedJob, @@ -54,8 +54,8 @@ pub trait SushApi { ctx: RequestContext, headers: Header, query: QueryParams, - body: TypedBody>, - ) -> Result, HttpError>; + body: TypedBody, + ) -> Result, HttpError>; /// List cached identities. /// @@ -281,6 +281,40 @@ pub struct JobTargetParams { pub target: String, } +/// Login evidence that accompanies initial credentials. +#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct IamRequest { + /// The key that signed the credentials. + pub public_key: Option, + + /// Another server's receipt for the credentials. + pub receipt: Option, +} + +impl IamRequest { + pub fn new(public_key: SshPublicKey, receipt: Option) -> Self { + Self { + public_key: Some(public_key), + receipt, + } + } +} + +impl From for IamRequest { + fn from(public_key: SshPublicKey) -> Self { + Self::new(public_key, None) + } +} + +/// The result of a successful login. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct IamResponse { + pub identity: Identity, + + /// A receipt for the login, present when the server issues receipts. + pub receipt: Option, +} + #[derive(Deserialize, JsonSchema)] pub struct SessionStartBody { pub client_nonce: SessionClientNonce, diff --git a/client/Cargo.toml b/client/Cargo.toml index 59d2e133..80f01c4f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -21,7 +21,6 @@ anstream.workspace = true anstyle.workspace = true anyhow = { workspace = true, optional = true } async-recursion.workspace = true -attest-data.workspace = true atomicwrites.workspace = true base64.workspace = true bytes.workspace = true @@ -56,7 +55,7 @@ signature.workspace = true sled-hardware-types.workspace = true ssh-key.workspace = true sush-api = { path = "../api" } -sush-common = { path = "../common" } +sush-common = { path = "../common", features = ["verify-vouchers"] } thiserror.workspace = true tokio.workspace = true tokio-fd.workspace = true diff --git a/client/src/commands.rs b/client/src/commands.rs index ed3837f2..eb39d8ff 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -42,7 +42,7 @@ use tokio_tungstenite::tungstenite::protocol::Role; use x509_cert::Certificate; use x509_cert::der::DecodePem as _; -use sush_api::{ErrorCode, JobWait}; +use sush_api::{ErrorCode, IamRequest, IamResponse, JobWait}; use sush_common::authn::{ AuthnError, Challenge, ChallengeResponse, Credentials, Identity, RequestKey, }; @@ -918,19 +918,37 @@ async fn authenticate( let mut request = client .iam() .authorization(credentials.to_string()) - .body(public_key.to_string()); + .body(IamRequest::from(public_key.clone())); if let Some(via) = via { request = request.via(via.to_string()); } - let identity = match request.send().await { - Ok(identity) => identity.into_inner(), + let IamResponse { identity, receipt } = match request.send().await { + Ok(response) => response.into_inner(), Err(error) if error.status() == Some(StatusCode::UNAUTHORIZED) => { let server = client.version().send().await.ok().map(|v| v.into_inner()); return Err(login_refused(server, error.into())); } Err(error) => return Err(error.into()), }; - Ok((identity, Authz::new(credentials, key))) + let mut authz = Authz::new(credentials, key); + authz.receipt = receipt.map(|receipt| IamRequest::new(public_key, Some(receipt))); + Ok((identity, authz)) +} + +/// Present the receipt in `authz` to sled `via`, and return whether +/// the sled accepted it. +async fn present_receipt(client: &Client, via: Option<&BaseboardId>, authz: Authz) -> bool { + let Some(receipt) = authz.receipt else { + return false; + }; + let mut request = client + .iam() + .authorization(authz.credentials.to_string()) + .body(receipt); + if let Some(via) = via { + request = request.via(via.to_string()); + } + request.send().await.is_ok() } /// A refused login usually indicates build skew rather than a bad key. @@ -960,8 +978,8 @@ where } /// 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. +/// `via`. If `via` refuses the request, present the current login's +/// receipt to it, and if it refuses that too, log in at `via`. async fn with_login_via( ctx: &mut impl CommandContext, client: &Client, @@ -974,6 +992,7 @@ where { let signer = ctx.authz_signer(); let mut generation = signer.generation(); + let mut presented = false; let mut logged_in = false; loop { match make_request().await { @@ -981,9 +1000,16 @@ 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?; - ctx.set_credentials(Some(authz)); - logged_in = true; + if !presented + && let Some(authz) = signer.get() + && present_receipt(client, via, authz).await + { + presented = true; + } else { + let (_identity, authz) = authenticate(ctx, client, via, err).await?; + ctx.set_credentials(Some(authz)); + logged_in = true; + } } generation = signer.generation(); } @@ -1029,10 +1055,9 @@ async fn iam( } IdentityCommand::Login => { - let identity = with_login(ctx, client, async || client.iam().body(None).send().await) - .await? - .into_inner(); - ctx.iam(&identity) + let request = async || client.iam().body(IamRequest::default()).send().await; + let response = with_login(ctx, client, request).await?.into_inner(); + ctx.iam(&response.identity) } } } diff --git a/client/src/context.rs b/client/src/context.rs index 805677fc..222eb041 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -13,6 +13,7 @@ use clap::ValueEnum; use sled_hardware_types::BaseboardId; use x509_cert::Certificate; +use sush_api::IamRequest; use sush_common::authn::{BoundRequest, Credentials, Identity, RequestKey}; use sush_common::jobs::{ Access, JobId, JobOutputStream, JobStartRequest, JobStatusMap, Session, SessionClientNonce, @@ -30,6 +31,7 @@ use crate::commands::{CommandError, GlobalArgs}; #[derive(Clone, Debug)] pub struct Authz { pub credentials: Credentials, + pub receipt: Option, key: RequestKey, seq: Arc, } @@ -38,6 +40,7 @@ impl Authz { pub fn new(credentials: Credentials, key: RequestKey) -> Self { Self { credentials, + receipt: None, key, seq: Arc::new(AtomicU64::new(1)), } diff --git a/client/src/lib.rs b/client/src/lib.rs index b3b62085..c5d243e6 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -136,6 +136,8 @@ progenitor::generate_api!( replace = { Access = sush_common::jobs::Access, BaseboardId = sled_hardware_types::BaseboardId, + IamRequest = sush_api::IamRequest, + IamResponse = sush_api::IamResponse, Identity = sush_common::authn::Identity, Session = sush_common::jobs::Session, SessionId = sush_common::jobs::SessionId, diff --git a/client/src/tls.rs b/client/src/tls.rs index 952b512c..d8c885c6 100644 --- a/client/src/tls.rs +++ b/client/src/tls.rs @@ -13,7 +13,6 @@ use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; use std::sync::Arc; use std::time::Duration; -use attest_data::{DICE_TCB_INFO, DiceTcbInfo}; use ed25519_dalek::{Signature, VerifyingKey}; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; @@ -21,11 +20,11 @@ use rustls::version::TLS13; use rustls::{CertificateError, ClientConfig, DigitallySignedStruct, SignatureScheme}; use slog::{Discard, Logger, o}; use sprockets_tls::keys::RotCertVerifier; -use sush_common::keys::{VOUCHER_OID, voucher_digest}; +use sush_common::keys::KeyError; +use sush_common::vouchers::{VoucherRole, verify_voucher}; use thiserror::Error; use x509_cert::Certificate; use x509_cert::der::{Decode as _, DecodePem as _}; -use x509_cert::ext::pkix::BasicConstraints; /// The same request timeout as the generated default client. const TIMEOUT: Duration = Duration::from_secs(600); @@ -160,7 +159,15 @@ impl ServerCertVerifier for PlatformVerifier { )); }; self.inner.verify_cert(platform, rest)?; - verify_vouched_cert(end_entity, platform)?; + let cert = Certificate::from_der(end_entity).map_err(|_| bad_encoding())?; + let platform = Certificate::from_der(platform).map_err(|_| bad_encoding())?; + verify_voucher(VoucherRole::Proxy, &cert, &platform).map_err(|error| match error { + KeyError::Der(_) => bad_encoding(), + KeyError::NotVouchingCert | KeyError::UnknownCriticalExtension(_) => { + rustls::Error::InvalidCertificate(CertificateError::InvalidPurpose) + } + _ => bad_signature(), + })?; Ok(ServerCertVerified::assertion()) } @@ -200,68 +207,6 @@ impl ServerCertVerifier for PlatformVerifier { } } -/// Verify the voucher (RFD 620 §4.6.2.1) on a certificate. -/// The cert must include a [`VOUCHER_OID`] extension containing -/// a valid signature over its tagged SPKI. -fn verify_vouched_cert( - cert: &CertificateDer<'_>, - platform: &CertificateDer<'_>, -) -> Result<(), rustls::Error> { - let cert = Certificate::from_der(cert).map_err(|_| bad_encoding())?; - let platform = Certificate::from_der(platform).map_err(|_| bad_encoding())?; - verify_vouching_cert(&platform)?; - let mut voucher = None; - for extension in cert - .tbs_certificate - .extensions - .as_deref() - .unwrap_or_default() - { - if extension.extn_id == VOUCHER_OID { - voucher = Some(extension.extn_value.as_bytes()); - } else if extension.critical { - return Err(rustls::Error::InvalidCertificate( - CertificateError::InvalidPurpose, - )); - } - } - let Some(voucher) = voucher else { - return Err(bad_signature()); - }; - let digest = voucher_digest(&cert.tbs_certificate.subject_public_key_info) - .map_err(|_| bad_encoding())?; - let signature = Signature::from_slice(voucher).map_err(|_| bad_signature())?; - spki_key(&platform)? - .verify_strict(&digest, &signature) - .map_err(|_| bad_signature()) -} - -/// Verify that a platform identity cert may vouch for the proxy key. -/// Any cert that chains to a platform root passes [`RotCertVerifier`], -/// including the CAs above the Trust Quorum cert, but only the RoT -/// may sign a voucher. We check that the vouching cert is *not* a CA -/// but *does* contain a plausible DICE TCB info constraint. -fn verify_vouching_cert(platform: &Certificate) -> Result<(), rustls::Error> { - let constraints = platform.tbs_certificate.get::(); - let tcb_info = platform - .tbs_certificate - .extensions - .as_deref() - .unwrap_or_default() - .iter() - .find(|ext| ext.extn_id == DICE_TCB_INFO) - .and_then(|ext| DiceTcbInfo::from_der(ext.extn_value.as_bytes()).ok()); - match (constraints, tcb_info) { - ( - Ok(Some((_, BasicConstraints { ca: false, .. }))), - Some(DiceTcbInfo { fwids: Some(fwids) }), - ) if !fwids.is_empty() => Ok(()), - _ => Err(rustls::Error::InvalidCertificate( - CertificateError::InvalidPurpose, - )), - } -} - /// The Ed25519 key in a certificate's SPKI. fn spki_key(cert: &Certificate) -> Result { let key: [u8; 32] = cert @@ -286,20 +231,6 @@ fn bad_signature() -> rustls::Error { mod test { use super::*; - /// The DICE TCB info extension from hubris's trust-quorum-dhe - /// cert template (lib/dice/src/trust_quorum_dhe_cert_tmpl.rs) - /// decodes as the type the voucher check requires. - #[test] - fn tq_dhe_tcb_info_decodes() { - let mut extn_value = vec![ - 0x30, 0x31, 0xa6, 0x2f, 0x30, 0x2d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, - 0x04, 0x02, 0x08, 0x04, 0x20, - ]; - extn_value.extend([0; 32]); - let tcb_info = DiceTcbInfo::from_der(&extn_value).unwrap(); - assert_eq!(tcb_info.fwids.unwrap().len(), 1); - } - #[test] fn descoped_urls() { let (url, resolve) = descope_url("https://[fe80::1%3]:12352").unwrap(); diff --git a/common/Cargo.toml b/common/Cargo.toml index 38e29197..0fa2307d 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/oxidecomputer/sush" publish = false [dependencies] +attest-data = { workspace = true, optional = true } bytes.workspace = true bytesize.workspace = true chrono.workspace = true @@ -37,5 +38,8 @@ tokio.workspace = true tokio-tungstenite.workspace = true x509-cert.workspace = true +[features] +verify-vouchers = ["dep:attest-data"] + [dev-dependencies] ciborium.workspace = true diff --git a/common/src/authn.rs b/common/src/authn.rs index bd593289..da5d7583 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -26,6 +26,7 @@ use std::collections::HashMap; use std::fmt; +use std::iter; use std::str::FromStr; use std::time::Duration; @@ -37,13 +38,16 @@ use rand_core::OsRng; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; +use x509_cert::Certificate; use crate::codephrase_newtype; use crate::codephrases::InvalidCodephrase; use crate::keys::{ - EccR, EccS, EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, - ToBeSignedFields, Verified, + EccR, EccS, EncodedSignature, EphemeralKey, KeyError, KeyId, Signed, Signer as _, SshPublicKey, + ToBeSigned, ToBeSignedFields, Verified, pem_cert_chain, }; +#[cfg(feature = "verify-vouchers")] +use crate::vouchers::{VoucherRole, verify_voucher}; /// The name of our custom HTTP authentication scheme. pub const AUTHN_SCHEME: &str = "Sush"; @@ -352,9 +356,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"; @@ -482,6 +483,87 @@ impl FromStr for Credentials { } } +/// The most certificates a receipt's chain may carry. This allows the +/// vouched cert, the five-cert TQ chain, and two more intermediates. +pub const MAX_RECEIPT_CHAIN_LEN: usize = 8; + +/// A sush server's signed statement that it verified a login. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct LoginReceipt { + /// The issuing server's signature over the login's credentials. + signature: EncodedSignature, + + /// The issuing server's vouched certificate, then the platform chain, + /// PEM encoded and newline separated. + cert_chain: String, +} + +impl LoginReceipt { + /// Sign a receipt for `credentials` with `key`. The caller must have + /// verified `credentials`, and the first cert in `platform` must + /// vouch for `key` as a server key. + pub async fn new( + key: &mut EphemeralKey, + platform: &[Certificate], + credentials: &Credentials, + ) -> Result { + let signed = key.sign(ReceiptFields(credentials)).await?; + let certs = iter::once(key.cert()).chain(platform).cloned().collect(); + Ok(Self { + signature: signed.signature().clone(), + cert_chain: pem_cert_chain(certs)?, + }) + } + + /// Verify that this receipt is signed over `credentials` with a + /// vouched server key. Return the platform chain, which the caller + /// must verify against its trust anchors. + #[cfg(feature = "verify-vouchers")] + pub fn verify(&self, credentials: &Credentials) -> Result, KeyError> { + let mut chain = Certificate::load_pem_chain(self.cert_chain.as_bytes())?; + if chain.len() > MAX_RECEIPT_CHAIN_LEN { + return Err(KeyError::ChainTooLong); + } + let [cert, platform, ..] = chain.as_slice() else { + return Err(KeyError::ChainTooShort); + }; + verify_voucher(VoucherRole::Server, cert, platform)?; + Signed::new( + ReceiptFields(credentials), + KeyId::try_from(cert)?, + self.signature.clone(), + ) + .verify_with_cert(cert)?; + Ok(chain.split_off(1)) + } +} + +/// The fields of a login that a receipt signs. +struct ReceiptFields<'a>(&'a Credentials); + +impl ReceiptFields<'_> { + const TYPE_NAME: &'static [u8] = b"sush_common::authn::LoginReceipt"; +} + +impl ToBeSigned for ReceiptFields<'_> { + /// Hash of the labeled login fields. + fn to_be_signed(&self) -> Vec { + let Credentials { + key_id, + nonce, + cnonce, + epk, + signature: _, + } = self.0; + ToBeSignedFields::new(Self::TYPE_NAME) + .field(b"key_id", &key_id.to_be_bytes()) + .field(b"nonce", &nonce.to_be_bytes()) + .field(b"cnonce", &cnonce.to_be_bytes()) + .field(b"epk", &epk.to_be_bytes()) + .finish() + } +} + /// An authenticated identity. #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] pub struct Identity { @@ -601,28 +683,30 @@ 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; - use crate::keys::{EphemeralKey, KeyType, Signer as _}; + use crate::keys::KeyType; 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"); } } diff --git a/common/src/keys.rs b/common/src/keys.rs index db126afa..6183fffe 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -47,6 +47,7 @@ use x509_cert::{Certificate, TbsCertificate, Version}; use crate::codephrases::InvalidCodephrase; use crate::hash::{Hasher, hash}; +use crate::vouchers::{VOUCHER_OID, VoucherRole, voucher_digest}; codephrase_newtype! { /// SHA-256 of a certificate subject or an identity public key, @@ -799,23 +800,6 @@ impl SigningKey { } } -/// A signature in a trenchcoat; see RFD 620 §4.6.2.1. Allocated in OANA. -pub const VOUCHER_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.57551.3.1"); - -/// Domain separation for [`voucher_digest`]. ASCII, so the preimage -/// can never collide with a DER certificate, which begins `0x30`. -const VOUCHER_TAG: &[u8] = b"sush-proxy-voucher-v0:"; - -/// Produce the SHA-3 digest the RoT signs to make a voucher for a key. -/// We must use only its public key info, because it is to be embedded -/// in a self-signed certificate. -pub fn voucher_digest(spki: &SubjectPublicKeyInfo) -> Result<[u8; 32], KeyError> { - let mut hasher = Hasher::new(); - hasher.update(VOUCHER_TAG); - hasher.update(&spki.to_der()?); - Ok(*hasher.finalize().as_bytes()) -} - pub struct EphemeralKey { key: SigningKey, key_id: KeyId, @@ -874,16 +858,17 @@ impl EphemeralKey { Ok(Self { key, key_id, cert }) } - /// Ephemeral key with a self-signed cert carrying a voucher - /// embedded as a critical extension under [`VOUCHER_OID`]. + /// Ephemeral key with a self-signed cert carrying a voucher for + /// `role`, embedded as a critical extension under [`VOUCHER_OID`]. pub fn new_vouched( + role: VoucherRole, key_type: KeyType, subject: Name, validity: Validity, vouch: impl FnOnce(&[u8; 32]) -> Result, E>, ) -> Result { let key = SigningKey::new(key_type); - let voucher = vouch(&voucher_digest(&key.spki())?).map_err(KeyError::signer)?; + let voucher = vouch(&voucher_digest(role, &key.spki())?).map_err(KeyError::signer)?; let signature_algorithm = key.signature_algorithm(); let mut tbs_certificate = Self::tbs_certificate( &key, @@ -1065,6 +1050,10 @@ pub fn pem_cert_chain(certs: Vec) -> Result { /// What went wrong handling a key, signature, or certificate. #[derive(Debug, Error)] pub enum KeyError { + #[error("Certificate chain too long")] + ChainTooLong, + #[error("Certificate chain too short")] + ChainTooShort, #[error("DER encoding error: {0}")] Der(#[from] x509_cert::der::Error), #[error(transparent)] @@ -1081,6 +1070,10 @@ pub enum KeyError { InvalidSignatureEncoding, #[error("Can't find certificate for key `{0}`")] MissingCert(KeyId), + #[error("Certificate is missing a voucher")] + MissingVoucher, + #[error("Certificate can't vouch for keys")] + NotVouchingCert, #[error(transparent)] Pem(#[from] pem_rfc7468::Error), #[error("PKCS#8 encoding error: {0}")] @@ -1095,6 +1088,8 @@ pub enum KeyError { SshKey(#[from] SshKeyError), #[error("Trust anchor `{0}` is not signed by a preceding anchor")] UnanchoredCert(KeyId), + #[error("Unknown critical certificate extension `{0}`")] + UnknownCriticalExtension(ObjectIdentifier), #[error("Signature does not attest user presence")] UserPresenceRequired, } @@ -1114,34 +1109,13 @@ mod test { use super::*; - /// The voucher digest is a convention shared with the - /// signatures on deployed proxy certificates. If this - /// fails, STOP: existing vouchers would no longer verify. - #[test] - fn pin_voucher_digest() { - let spki = SubjectPublicKeyInfo { - algorithm: AlgorithmIdentifierOwned { - oid: ID_ED_25519, - parameters: None, - }, - subject_public_key: BitString::from_bytes(&[0x42; 32]).unwrap(), - }; - assert_eq!( - voucher_digest(&spki).unwrap(), - [ - 0x3c, 0x39, 0xf5, 0x94, 0x94, 0xa7, 0x6c, 0xe4, 0x37, 0x01, 0x97, 0xa3, 0xfc, 0xbd, - 0xf1, 0x28, 0x52, 0x3f, 0x87, 0x0b, 0x07, 0xf2, 0x44, 0x99, 0xcf, 0xf0, 0xdb, 0x14, - 0x97, 0x03, 0xa6, 0xeb, - ], - ); - } - /// A vouched certificate is self-signed, has the voucher as its /// one critical extension, and verifies against the vouching key. #[test] fn vouched_cert_round_trip() { let authority = ed25519_dalek::SigningKey::generate(&mut OsRng); let key = EphemeralKey::new_vouched( + VoucherRole::Proxy, KeyType::Ed25519, "CN=vouched".parse().unwrap(), Validity::from_now(Duration::from_secs(60)).unwrap(), @@ -1155,7 +1129,11 @@ mod test { assert_eq!(extension.extn_id, VOUCHER_OID); assert!(extension.critical); - let digest = voucher_digest(&key.cert().tbs_certificate.subject_public_key_info).unwrap(); + let digest = voucher_digest( + VoucherRole::Proxy, + &key.cert().tbs_certificate.subject_public_key_info, + ) + .unwrap(); let signature = ed25519_dalek::Signature::from_slice(extension.extn_value.as_bytes()).unwrap(); authority diff --git a/common/src/lib.rs b/common/src/lib.rs index ab506878..b93ce0be 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -13,6 +13,7 @@ pub mod jobs; pub mod keys; pub mod targets; pub mod version; +pub mod vouchers; pub mod wire; pub mod wordlist; diff --git a/common/src/vouchers.rs b/common/src/vouchers.rs new file mode 100644 index 00000000..f0e32fb4 --- /dev/null +++ b/common/src/vouchers.rs @@ -0,0 +1,188 @@ +// 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/. + +//! A *voucher* is an RoT signature over an ephemeral key, +//! embedded in that key's self-signed certificate; see +//! RFD 620 §4.6.2.1. + +use x509_cert::der::Encode as _; +use x509_cert::der::asn1::{Any, BitString}; +use x509_cert::der::oid::ObjectIdentifier; +use x509_cert::spki::SubjectPublicKeyInfo; + +use crate::hash::Hasher; +use crate::keys::KeyError; + +#[cfg(feature = "verify-vouchers")] +pub use verify::verify_voucher; + +/// A signature in a trenchcoat. Allocated in OANA. +pub const VOUCHER_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.57551.3.1"); + +/// What a vouched key is for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VoucherRole { + /// A proxy TLS key, for securing client connections. + Proxy, + + /// A sush server key, for signing login receipts. + Server, +} + +impl VoucherRole { + /// Domain separation for [`voucher_digest`]. Each role has its own + /// tag, so a voucher for one role's key never verifies for another. + /// The tags are ASCII, so a preimage never collides with a DER + /// certificate, which begins `0x30`. + fn tag(self) -> &'static [u8] { + match self { + Self::Proxy => b"sush-proxy-voucher-v0:", + Self::Server => b"sush-server-voucher-v0:", + } + } +} + +/// Compute the SHA-3 digest the RoT signs to make a voucher for a key. +/// We must use only its public key info, because it is to be embedded +/// in a self-signed certificate. +pub fn voucher_digest( + role: VoucherRole, + spki: &SubjectPublicKeyInfo, +) -> Result<[u8; 32], KeyError> { + let mut hasher = Hasher::new(); + hasher.update(role.tag()); + hasher.update(&spki.to_der()?); + Ok(*hasher.finalize().as_bytes()) +} + +#[cfg(feature = "verify-vouchers")] +mod verify { + use attest_data::{DICE_TCB_INFO, DiceTcbInfo}; + use ed25519_dalek::{Signature as Ed25519Signature, VerifyingKey as Ed25519VerifyingKey}; + use x509_cert::Certificate; + use x509_cert::der::Decode as _; + use x509_cert::ext::pkix::BasicConstraints; + + use super::{VOUCHER_OID, VoucherRole, voucher_digest}; + use crate::keys::KeyError; + + /// Verify that `cert` carries a valid voucher for `role` from `platform`. + /// Vouchers are encoded as a [`VOUCHER_OID`] extension with a signature + /// over [`voucher_digest`]. The caller must verify `platform` itself + /// against the platform roots. + pub fn verify_voucher( + role: VoucherRole, + cert: &Certificate, + platform: &Certificate, + ) -> Result<(), KeyError> { + verify_vouching_cert(platform)?; + let mut voucher = None; + for extension in cert + .tbs_certificate + .extensions + .as_deref() + .unwrap_or_default() + { + if extension.extn_id == VOUCHER_OID { + voucher = Some(extension.extn_value.as_bytes()); + } else if extension.critical { + return Err(KeyError::UnknownCriticalExtension(extension.extn_id)); + } + } + let voucher = voucher.ok_or(KeyError::MissingVoucher)?; + let digest = voucher_digest(role, &cert.tbs_certificate.subject_public_key_info)?; + let signature = Ed25519Signature::from_slice(voucher)?; + spki_ed25519_key(platform)?.verify_strict(&digest, &signature)?; + Ok(()) + } + + /// Verify that a platform identity cert may vouch for a key. Any cert + /// that chains to a platform root is a platform identity cert, + /// including the CAs above the Trust Quorum cert, but only the RoT may + /// sign a voucher. We check that the vouching cert is *not* a CA but + /// *does* contain a plausible DICE TCB info constraint. + fn verify_vouching_cert(platform: &Certificate) -> Result<(), KeyError> { + let constraints = platform.tbs_certificate.get::(); + let tcb_info = platform + .tbs_certificate + .extensions + .as_deref() + .unwrap_or_default() + .iter() + .find(|ext| ext.extn_id == DICE_TCB_INFO) + .and_then(|ext| DiceTcbInfo::from_der(ext.extn_value.as_bytes()).ok()); + match (constraints, tcb_info) { + ( + Ok(Some((_, BasicConstraints { ca: false, .. }))), + Some(DiceTcbInfo { fwids: Some(fwids) }), + ) if !fwids.is_empty() => Ok(()), + _ => Err(KeyError::NotVouchingCert), + } + } + + /// The Ed25519 key in a certificate's SPKI. + fn spki_ed25519_key(cert: &Certificate) -> Result { + let key: [u8; 32] = cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes() + .try_into() + .map_err(|_| KeyError::InvalidPublicKey)?; + Ok(Ed25519VerifyingKey::from_bytes(&key)?) + } + + #[cfg(test)] + mod test { + use super::*; + + /// The DICE TCB info extension from hubris's trust-quorum-dhe + /// cert template (lib/dice/src/trust_quorum_dhe_cert_tmpl.rs) + /// decodes as the type the voucher check requires. + #[test] + fn voucher_check_reads_hubris_tcb_info() { + let mut extn_value = vec![ + 0x30, 0x31, 0xa6, 0x2f, 0x30, 0x2d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, + 0x04, 0x02, 0x08, 0x04, 0x20, + ]; + extn_value.extend([0; 32]); + let tcb_info = DiceTcbInfo::from_der(&extn_value).unwrap(); + assert_eq!(tcb_info.fwids.unwrap().len(), 1); + } + } +} + +#[cfg(test)] +mod test { + use x509_cert::der::oid::db::rfc8410::ID_ED_25519; + use x509_cert::spki::AlgorithmIdentifierOwned; + + use super::*; + + /// The voucher digest is a convention shared with the + /// signatures on deployed proxy certificates. If this + /// fails, STOP: existing vouchers would no longer verify. + #[test] + fn pin_voucher_digest() { + let spki = SubjectPublicKeyInfo { + algorithm: AlgorithmIdentifierOwned { + oid: ID_ED_25519, + parameters: None, + }, + subject_public_key: BitString::from_bytes(&[0x42; 32]).unwrap(), + }; + assert_eq!( + voucher_digest(VoucherRole::Proxy, &spki).unwrap(), + [ + 0x3c, 0x39, 0xf5, 0x94, 0x94, 0xa7, 0x6c, 0xe4, 0x37, 0x01, 0x97, 0xa3, 0xfc, 0xbd, + 0xf1, 0x28, 0x52, 0x3f, 0x87, 0x0b, 0x07, 0xf2, 0x44, 0x99, 0xcf, 0xf0, 0xdb, 0x14, + 0x97, 0x03, 0xa6, 0xeb, + ], + ); + assert_ne!( + voucher_digest(VoucherRole::Server, &spki).unwrap(), + voucher_digest(VoucherRole::Proxy, &spki).unwrap(), + ); + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 9701844a..cb6f7b33 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -24,6 +24,7 @@ bytesize.workspace = true chrono.workspace = true async-trait.workspace = true clap.workspace = true +dice-mfg-msgs.workspace = true dropshot.workspace = true ed25519-dalek.workspace = true futures.workspace = true @@ -52,7 +53,7 @@ slog-term.workspace = true sprockets-tls.workspace = true ssh-key.workspace = true sush-api = { path = "../api" } -sush-common = { path = "../common" } +sush-common = { path = "../common", features = ["verify-vouchers"] } tempfile.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/server/src/gossip.rs b/server/src/gossip.rs index f992f105..19085bce 100644 --- a/server/src/gossip.rs +++ b/server/src/gossip.rs @@ -57,6 +57,12 @@ impl LinkedBaseboards { Self(rx) } + /// A set that never changes. + pub fn fixed(sleds: BTreeSet) -> Self { + let (_tx, rx) = watch::channel(sleds); + Self(rx) + } + pub fn borrow(&self) -> watch::Ref<'_, BTreeSet> { self.0.borrow() } 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..5c5a60b2 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; @@ -30,6 +29,7 @@ pub mod output; pub mod proxy; pub mod pty; pub mod rack; +pub mod receipts; pub mod server; pub mod spawn; pub mod state; diff --git a/server/src/link.rs b/server/src/link.rs index c0ca9017..bc3ecbea 100644 --- a/server/src/link.rs +++ b/server/src/link.rs @@ -202,7 +202,7 @@ impl Baseboards { } /// The baseboard a platform id names (`prefix:part:revision:serial`). -fn baseboard(log: &Logger, platform_id: &str) -> Option { +pub(crate) fn baseboard(log: &Logger, platform_id: &str) -> Option { let mut fields = platform_id.split(':'); match (fields.nth(1), fields.nth(1)) { (Some(part_number), Some(serial_number)) => Some(BaseboardId { diff --git a/server/src/main.rs b/server/src/main.rs index 7cae3f7e..853fe97d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -25,6 +25,7 @@ use sush_server::gossip::{LinkedBaseboards, Universe}; use sush_server::locker::Locker; use sush_server::manager::JobManager; use sush_server::output::JobOutputDir; +use sush_server::receipts::Receipts; use sush_server::server::ApiServer; use sush_server::{read_certs, seed_gossip}; @@ -112,6 +113,7 @@ async fn main() -> Result<(), String> { cubbies, gossip, LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &anchors, shutdown.clone(), diff --git a/server/src/manager.rs b/server/src/manager.rs index 9bfb4aa0..20572d05 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken; use x509_cert::Certificate; use x509_cert::der::DecodePem as _; -use sush_api::{JobStartParams, JobStopParams, JobWait}; +use sush_api::{IamRequest, IamResponse, JobStartParams, JobStopParams, JobWait}; use sush_common::authn::{ Authn, BoundRequest, Credentials, IDENTITY_IDLE_TTL, IDENTITY_MAX_TTL, Identity, NONCE_TTL, Nonce, RequestVerifier, SeqWindow, @@ -36,7 +36,7 @@ use sush_common::jobs::{ 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,8 +46,9 @@ 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::receipts::Receipts; use crate::state::{GossipUniverse, State, StateManager}; /// Maximum number of cached identities. @@ -93,6 +94,7 @@ pub struct JobManager { output_dir: JobOutputDir, own_baseboard: BaseboardId, linked: LinkedBaseboards, + receipts: Receipts, state: watch::Receiver, // from the state manager tx_req: mpsc::Sender, // to the state manager join_state: Option>, @@ -110,6 +112,7 @@ impl JobManager { cubbies: watch::Receiver, universe: watch::Receiver, linked: LinkedBaseboards, + receipts: Receipts, locker: &Locker, anchors: &[impl AsRef], shutdown: CancellationToken, @@ -123,6 +126,7 @@ impl JobManager { cubbies, universe, linked, + receipts, locker, &anchors, shutdown, @@ -139,6 +143,7 @@ impl JobManager { cubbies: watch::Receiver, universe: watch::Receiver, linked: LinkedBaseboards, + receipts: Receipts, locker: &Locker, anchors: &[Certificate], shutdown: CancellationToken, @@ -168,6 +173,7 @@ impl JobManager { session_server_nonce, own_baseboard, linked, + receipts, output_dir, state: rx_state, tx_req, @@ -226,17 +232,6 @@ impl JobManager { .map_err(|_| JobError::ChannelClosed) } - async fn identity_request( - &self, - actor: KeyId, - request: IdentityRequest, - ) -> Result<(), JobError> { - self.tx_req - .send(Request::identity(actor, request)) - .await - .map_err(|_| JobError::ChannelClosed) - } - // Certificate management. pub fn cert_chain( @@ -249,15 +244,26 @@ impl JobManager { // 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. + /// Authorize a bound request. pub async fn iam( &self, authorization: Option, - public_key: Option, request: (&str, &str), ) -> Result { + self.login(authorization, IamRequest::default(), request) + .await + .map(|response| response.identity) + } + + /// Authenticate initial credentials at `iam`, or authorize a bound + /// request. Initial credentials must sign a nonce this server issued, + /// or come with a receipt from a linked sled. + pub async fn login( + &self, + authorization: Option, + login: IamRequest, + 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. @@ -294,30 +300,6 @@ impl JobManager { 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"); }; @@ -333,7 +315,10 @@ impl JobManager { } cached.last_used = Instant::now(); debug!(self.log, "bound request authorized"; "key_id" => %bound.key_id); - return Ok(cached.identity.clone()); + return Ok(IamResponse { + identity: cached.identity.clone(), + receipt: None, + }); } Authn::Initial(credentials) => *credentials, }; @@ -345,42 +330,64 @@ impl JobManager { 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 IamRequest { + public_key, + receipt, + } = login; let Some(public_key) = public_key else { unauthorized!("missing public key"); }; + + // Verify the supplied credentials. 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)); + let verified = try_authn!(response.verify_with_ssh_public_key(&public_key)); + let identity = try_authn!(Identity::new(public_key, verified, now)); + let cached = CachedIdentity { + identity: identity.clone(), + verifier: credentials.epk.clone(), + window: SeqWindow::default(), + authenticated: Instant::now(), + last_used: Instant::now(), + }; + + // Accept a valid receipt from a linked sled. + if let Some(receipt) = receipt { + let issuer = try_authn!(self.receipts.verify(&receipt, &credentials)); + if !self.linked.borrow().contains(&issuer) { + unauthorized!(format!("receipt from unlinked sled {issuer}")); + } + debug!(self.log, "adopting login with receipt"; "key_id" => %key_id, "issuer" => %issuer); + let mut identities = self.identities.lock().await; + let key = (key_id, nonce); + return Ok(IamResponse { + identity: if let Some(entry) = identities.get(&key) + && entry.is_still_valid() + { + entry.identity.clone() + } else { + identities.put(key, cached); + identity + }, + receipt: None, + }); + } + + // 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"); + }; - // Authenticated! Cache the identity and its request verifier, - // and gossip the evidence. + // 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.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) + self.identities.lock().await.put((key_id, nonce), cached); + let receipt = self.receipts.issue(&credentials).await; + Ok(IamResponse { identity, receipt }) } pub async fn identities(&self, _authn: &Identity) -> 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/receipts.rs b/server/src/receipts.rs new file mode 100644 index 00000000..9ce2a3b0 --- /dev/null +++ b/server/src/receipts.rs @@ -0,0 +1,123 @@ +// 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/. + +//! Issue and verify login receipts. + +use std::fmt; + +use dice_mfg_msgs::{PlatformId, PlatformIdPkiPathError}; +use rustls::pki_types::CertificateDer; +use sled_hardware_types::BaseboardId; +use slog::{Discard, Logger, error, o}; +use sprockets_tls::keys::RotCertVerifier; +use thiserror::Error; +use tokio::sync::Mutex; +use x509_cert::Certificate; +use x509_cert::der::Encode as _; + +use sush_common::authn::{Credentials, LoginReceipt}; +use sush_common::keys::{EphemeralKey, KeyError}; + +use crate::link::baseboard; + +#[derive(Debug, Error)] +pub enum ReceiptError { + #[error("Unparseable platform ID `{0}`")] + Baseboard(String), + #[error("Platform chain does not verify: {0}")] + Chain(#[from] rustls::Error), + #[error("This server does not accept login receipts")] + Disabled, + #[error(transparent)] + Key(#[from] KeyError), + #[error(transparent)] + PlatformId(#[from] PlatformIdPkiPathError), +} + +/// What a server needs to issue and verify login receipts. +pub struct Receipts { + log: Logger, + + /// This server's vouched key and its platform chain. Without one, + /// the server issues no receipts. + issuer: Option)>>, + + /// Verifies issuers' platform chains against the platform roots. + /// Without one, the server accepts no receipts. + verifier: Option, +} + +impl fmt::Debug for Receipts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Receipts") + .field("issues", &self.issuer.is_some()) + .field("verifies", &self.verifier.is_some()) + .finish() + } +} + +impl Receipts { + /// Neither issue nor accept receipts. + pub fn none() -> Self { + Self { + log: Logger::root(Discard, o!()), + issuer: None, + verifier: None, + } + } + + /// Accept receipts whose platform chains verify against `roots`. + /// Given an issuer, also issue receipts signed with its key. + pub fn new( + log: &Logger, + roots: Vec, + issuer: Option<(EphemeralKey, Vec)>, + ) -> Self { + Self { + log: log.clone(), + issuer: issuer.map(Mutex::new), + verifier: Some(RotCertVerifier { + roots, + log: log.clone(), + }), + } + } + + /// Sign a receipt for `credentials`, which the caller has verified. + /// Return nothing if this server has no issuer or signing fails. + pub async fn issue(&self, credentials: &Credentials) -> Option { + let mut issuer = self.issuer.as_ref()?.lock().await; + let (key, platform) = &mut *issuer; + match LoginReceipt::new(key, platform, credentials).await { + Ok(receipt) => Some(receipt), + Err(error) => { + error!(self.log, "login receipt signing failed"; "error" => %error); + None + } + } + } + + /// Verify that `receipt` signs `credentials` and that its platform + /// chain verifies against the platform roots. Return the baseboard + /// of the issuing sled. + pub fn verify( + &self, + receipt: &LoginReceipt, + credentials: &Credentials, + ) -> Result { + let verifier = self.verifier.as_ref().ok_or(ReceiptError::Disabled)?; + let platform = receipt.verify(credentials)?; + let der = platform + .iter() + .map(|cert| Ok(CertificateDer::from(cert.to_der()?))) + .collect::, KeyError>>()?; + let [leaf, intermediates @ ..] = der.as_slice() else { + return Err(KeyError::ChainTooShort.into()); + }; + verifier.verify_cert(leaf, intermediates)?; + let platform_id = PlatformId::try_from(&platform)?; + baseboard(&self.log, platform_id.as_str()) + .ok_or_else(|| ReceiptError::Baseboard(platform_id.as_str().to_owned())) + } +} diff --git a/server/src/server.rs b/server/src/server.rs index c1fdee60..834ce794 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -22,15 +22,16 @@ use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig}; use sush_api::{ - AccessParam, Authorization, AuthorizedRangeRequest, JobHistoryParams, JobIdParam, - JobOutputParams, JobStartParams, JobStarted, JobStopParams, JobTargetParams, KeyIdParam, - RoutingParam, SessionAndJobIds, SessionAndKeyIds, SessionIdParam, SessionStartBody, SushApi, + AccessParam, Authorization, AuthorizedRangeRequest, IamRequest, IamResponse, JobHistoryParams, + JobIdParam, JobOutputParams, JobStartParams, JobStarted, JobStopParams, JobTargetParams, + KeyIdParam, RoutingParam, SessionAndJobIds, SessionAndKeyIds, SessionIdParam, SessionStartBody, + SushApi, }; use sush_common::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::pem_cert_chain; use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; @@ -63,9 +64,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; 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)?; @@ -78,15 +77,14 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, _query: QueryParams, - body: TypedBody>, - ) -> Result, HttpError> { + 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)) + let response = mgr + .login(authorization, body.into_inner(), request_line(&ctx.request)) .await?; - Ok(HttpResponseOk(identity)) + Ok(HttpResponseOk(response)) } async fn identities( @@ -95,9 +93,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; Ok(HttpResponseOk(mgr.identities(&authn).await?)) } @@ -109,9 +105,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; if let Some(session) = mgr.session(&authn) { Ok(HttpResponseOk(session)) } else { @@ -125,9 +119,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; Ok(HttpResponseOk(mgr.fresh_server_nonce())) } @@ -139,9 +131,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let SessionIdParam { session_id } = params.into_inner(); let SessionStartBody { client_nonce, @@ -160,9 +150,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let SessionIdParam { session_id } = params.into_inner(); mgr.session_stop(&authn, session_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -175,9 +163,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let SessionAndJobIds { session_id, job_id } = params.into_inner(); mgr.session_skip_job(&authn, session_id, job_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -191,9 +177,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; 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 +192,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let SessionAndKeyIds { session_id, key_id } = params.into_inner(); mgr.session_deny_attach(&authn, session_id, key_id).await?; Ok(HttpResponseUpdatedNoContent()) @@ -227,9 +209,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let JobTargetParams { job_id, target: _ } = params.into_inner(); let job = body.into_inner(); if *job.job_id() != job_id { @@ -251,9 +231,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let JobIdParam { job_id } = params.into_inner(); mgr.job_stop(&authn, &job_id, query.into_inner()).await?; Ok(HttpResponseUpdatedNoContent()) @@ -267,9 +245,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let JobIdParam { job_id } = params.into_inner(); Ok(HttpResponseOk(job_status_to_json_map( mgr.job_status(&authn, &job_id).await?, @@ -286,7 +262,7 @@ impl SushApi for ApiServer { let headers = headers.into_inner(); let range = headers.range()?; let authn = mgr - .iam(headers.authorization, None, request_line(&ctx.request)) + .iam(headers.authorization, request_line(&ctx.request)) .await?; let JobOutputParams { job_id, @@ -326,9 +302,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; let JobTargetParams { job_id, target } = params.into_inner(); let target = if target == "*" { mgr.own_baseboard().clone() @@ -362,9 +336,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 = mgr.iam(authorization, request_line(&ctx.request)).await?; 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/distributed.rs b/server/tests/distributed.rs index c4231082..f2651009 100644 --- a/server/tests/distributed.rs +++ b/server/tests/distributed.rs @@ -38,6 +38,7 @@ use sush_server::locker::Locker; use sush_server::locker::bookmark::BOOKMARK; use sush_server::messages::v0::{Event, JobEvent, Message}; use sush_server::output::JobOutputDir; +use sush_server::receipts::Receipts; use sush_server::state::GossipUniverse; use sush_server::{JobError, JobManager, seed_gossip}; @@ -103,6 +104,7 @@ impl Sled { cubbies_rx, universe.clone(), linked, + Receipts::none(), &locker, from_ref(root_pem), shutdown.clone(), 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/sush.json b/sush.json index 0bcca7f1..2c9824c5 100644 --- a/sush.json +++ b/sush.json @@ -113,9 +113,7 @@ "content": { "application/json": { "schema": { - "nullable": true, - "title": "Nullable_String", - "type": "string" + "$ref": "#/components/schemas/IamRequest" } } }, @@ -127,7 +125,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Identity" + "$ref": "#/components/schemas/IamResponse" } } } @@ -1040,6 +1038,47 @@ "request_id" ] }, + "IamRequest": { + "description": "Login evidence that accompanies initial credentials.", + "type": "object", + "properties": { + "public_key": { + "nullable": true, + "description": "The key that signed the credentials.", + "type": "string" + }, + "receipt": { + "nullable": true, + "description": "Another server's receipt for the credentials.", + "allOf": [ + { + "$ref": "#/components/schemas/LoginReceipt" + } + ] + } + } + }, + "IamResponse": { + "description": "The result of a successful login.", + "type": "object", + "properties": { + "identity": { + "$ref": "#/components/schemas/Identity" + }, + "receipt": { + "nullable": true, + "description": "A receipt for the login, present when the server issues receipts.", + "allOf": [ + { + "$ref": "#/components/schemas/LoginReceipt" + } + ] + } + }, + "required": [ + "identity" + ] + }, "Identity": { "description": "An authenticated identity.", "type": "object", @@ -1407,6 +1446,28 @@ } ] }, + "LoginReceipt": { + "description": "A sush server's signed statement that it verified a login.", + "type": "object", + "properties": { + "cert_chain": { + "description": "The issuing server's vouched certificate, then the platform chain, PEM encoded and newline separated.", + "type": "string" + }, + "signature": { + "description": "The issuing server's signature over the login's credentials.", + "allOf": [ + { + "$ref": "#/components/schemas/EncodedSignature" + } + ] + } + }, + "required": [ + "cert_chain", + "signature" + ] + }, "Nonce": { "description": "A unique random string. Authentication credentials have two of these: one generated by the server, and one by the client. This structure is agnostic to the syntax of the string.", "type": "string" diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index 6f404f8c..8038d695 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -30,8 +30,8 @@ use sprockets_tls_test_utils::{cert_path, root_prefix}; use x509_cert::Certificate; use x509_cert::der::DecodePem as _; -use sush_api::JobWait; use sush_api::sush_api_mod::api_description; +use sush_api::{IamRequest, JobWait}; use sush_client::tls::client as tls_client; use sush_client::tunnel::{Tunnel, pipe}; use sush_client::{AuthzSigner, Client, Error as ClientError}; @@ -75,7 +75,12 @@ async fn client_server() { 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(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -84,12 +89,12 @@ async fn client_server() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate") .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(iam.identity, identity, "who am I?"); // Start a session and run a job. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -180,7 +185,12 @@ async fn client_proxy_server() { // Connect and authenticate to the server via the proxy. let signer = AuthzSigner::default(); let client = Client::new(&format!("http://{proxy_addr}"), signer.clone()); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -189,12 +199,12 @@ async fn client_proxy_server() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate") .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(iam.identity, identity, "who am I?"); // Attach to an interactive job through the proxy, routed by the // target path segment, and echo bytes over the bridged upgrade. @@ -286,7 +296,12 @@ async fn client_proxy_server() { // With no sleds in the table, everything is refused. tx_targets.send_replace(BTreeMap::new()); - let unavailable = client.iam().body(None).send().await.unwrap_err(); + let unavailable = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err(); if let ClientError::UnexpectedResponse(response) = unavailable { assert_eq!(response.status(), 503); } @@ -349,7 +364,12 @@ async fn interactive_job() { 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(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -358,12 +378,12 @@ async fn interactive_job() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate") .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(iam.identity, identity, "who am I?"); // Start a session and run an interactive job. let (session_id, body) = http_session_ceremony(&client, 1).await; @@ -468,8 +488,12 @@ async fn interactive_job() { let mut guest_key = ephemeral_test_root(); let guest_signer = AuthzSigner::default(); let guest_client = Client::new(&format!("http://{addr}"), guest_signer.clone()); - let ClientError::ErrorResponse(unauthz) = - guest_client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(unauthz) = guest_client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -651,7 +675,12 @@ async fn streaming_job() { 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(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -795,7 +824,12 @@ async fn streaming_job_linger() { 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(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -985,7 +1019,12 @@ async fn client_tunnels_through_nexus() { tls_client(roots, None).unwrap(), signer.clone(), ); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -994,12 +1033,12 @@ async fn client_tunnels_through_nexus() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate") .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(iam.identity, identity, "who am I?"); // A tunnel whose Nexus URL names an unresolvable host still works // when --nexus-resolve supplies the address. @@ -1014,7 +1053,12 @@ async fn client_tunnels_through_nexus() { tls_client(roots, None).unwrap(), signer.clone(), ); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -1022,10 +1066,10 @@ async fn client_tunnels_through_nexus() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate via the resolved tunnel") .into_inner(); - assert_eq!(iam, identity, "who am I, resolved?"); + assert_eq!(iam.identity, identity, "who am I, resolved?"); } diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 2da8c2a2..0d441925 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -7,6 +7,8 @@ mod integration_tests; #[cfg(test)] mod manager_tests; #[cfg(test)] +mod receipt_tests; +#[cfg(test)] pub mod test_utils; #[cfg(test)] mod tls_tests; diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 10e52924..f9713ab6 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -23,9 +23,9 @@ use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; use x509_cert::time::Validity; -use sush_api::{JobStartParams, JobStopParams, JobWait}; +use sush_api::{IamRequest, JobStartParams, JobStopParams, JobWait}; use sush_client::context::Authz; -use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; +use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, RequestKey}; use sush_common::jobs::{ Access, JobId, JobLimits, JobMode, JobOutputState, JobOutputStream::*, JobStartRequest, JobStatus, ProcessError, Session, SessionId, SessionPreimage, SessionSeq, SessionSignerNonce, @@ -36,8 +36,9 @@ 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::receipts::Receipts; use sush_server::{JobError, JobManager}; use crate::test_utils::{ @@ -1032,6 +1033,7 @@ async fn cubby_targets() { cubbies_rx, Universe::isolated(null_gossip().await), LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &[root.cert().to_owned()], CancellationToken::new(), @@ -1109,6 +1111,7 @@ async fn root_certs_from_files() { no_cubbies(), Universe::isolated(null_gossip().await), LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &[path], CancellationToken::new(), @@ -1165,6 +1168,7 @@ async fn bad_root_cert_files() { no_cubbies(), Universe::isolated(null_gossip().await), LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &[path], CancellationToken::new(), @@ -1197,6 +1201,7 @@ async fn job_output_dir_moves() { no_cubbies(), Universe::isolated(null_gossip().await), LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &[root.cert().to_owned()], CancellationToken::new(), @@ -1303,6 +1308,7 @@ async fn universe_swap() { no_cubbies(), universe_rx, LinkedBaseboards::lonely(), + Receipts::none(), &Locker::null(), &[root.cert().to_owned()], CancellationToken::new(), @@ -1623,77 +1629,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. @@ -2392,8 +2327,7 @@ async fn output_ranges() { 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 { + let JobError::Unauthorized(nonce) = mgr.iam(None, ("POST", "/iam")).await.unwrap_err() else { panic!("should not be authorized yet"); }; @@ -2409,23 +2343,25 @@ async fn iam() { credentials.key_id = key_id.clone(); // override cert key ID // Register our identity. Initial credentials work only at `iam`. + let login = IamRequest::from(public_key.clone()); assert!( - mgr.iam( + mgr.login( Some(credentials.to_string()), - Some(public_key.clone()), + login.clone(), ("GET", "/sessions"), ) .await .is_err() ); let identity = mgr - .iam( + .login( Some(credentials.to_string()), - Some(public_key.clone()), + login.clone(), ("POST", "/iam"), ) .await - .unwrap(); + .unwrap() + .identity; let Identity { key_id: iam_key_id, public_key: iam_public_key, @@ -2442,23 +2378,19 @@ async fn iam() { let authz = Authz::new(credentials.clone(), request_key); let header = authz.header("GET", "/sessions"); assert_eq!( - mgr.iam(Some(header.clone()), None, ("GET", "/sessions")) + mgr.iam(Some(header.clone()), ("GET", "/sessions")) .await .unwrap() .key_id, key_id, ); - assert!( - mgr.iam(Some(header), None, ("GET", "/sessions")) - .await - .is_err() - ); + assert!(mgr.iam(Some(header), ("GET", "/sessions")).await.is_err()); let header = authz.header("GET", "/sessions"); - assert!(mgr.iam(Some(header), None, ("GET", "/jobs")).await.is_err()); + assert!(mgr.iam(Some(header), ("GET", "/jobs")).await.is_err()); // Replayed initial credentials are rejected: the nonce is spent. assert!( - mgr.iam(Some(credentials.to_string()), None, ("POST", "/iam")) + mgr.login(Some(credentials.to_string()), login, ("POST", "/iam")) .await .is_err() ); diff --git a/tests/src/receipt_tests.rs b/tests/src/receipt_tests.rs new file mode 100644 index 00000000..dcdb433d --- /dev/null +++ b/tests/src/receipt_tests.rs @@ -0,0 +1,247 @@ +// 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/. + +//! A login receipt verifies only for the login it signs, and only with +//! a key that the platform identity vouches for as a server key. A +//! server adopts a receipted login only from a sled it links to. + +use std::collections::BTreeSet; +use std::time::Duration; + +use function_name::named; +use tokio::test; +use x509_cert::time::Validity; + +use sush_api::{IamRequest, IamResponse}; +use sush_client::context::Authz; +use sush_common::authn::{ + Challenge, ChallengeResponse, Credentials, LoginReceipt, Nonce, RequestKey, +}; +use sush_common::keys::{EphemeralKey, KeyError, KeyType, Signer as _}; +use sush_common::vouchers::VoucherRole; +use sush_server::gossip::LinkedBaseboards; +use sush_server::receipts::{ReceiptError, Receipts}; +use sush_server::{JobError, JobManager}; + +use crate::test_utils::{ + fake_credentials, manager_with_receipts, pki_baseboard, pki_roots, test_logger, test_pki, + vouched_key, +}; + +fn user_key() -> EphemeralKey { + EphemeralKey::new_root( + KeyType::Ed25519, + "CN=user".parse().unwrap(), + Validity::from_now(Duration::from_secs(60)).unwrap(), + ) + .unwrap() +} + +#[test] +async fn receipt_verifies() { + let (_tmp, pki) = test_pki("receipt-verifies"); + let (mut key, platform) = vouched_key(&pki, VoucherRole::Server); + let credentials = fake_credentials(&mut user_key()).await; + let receipt = LoginReceipt::new(&mut key, &platform, &credentials) + .await + .unwrap(); + assert_eq!(receipt.verify(&credentials).unwrap(), platform); +} + +#[test] +async fn receipt_for_another_login_is_refused() { + let (_tmp, pki) = test_pki("receipt-other-login"); + let (mut key, platform) = vouched_key(&pki, VoucherRole::Server); + let credentials = fake_credentials(&mut user_key()).await; + let receipt = LoginReceipt::new(&mut key, &platform, &credentials) + .await + .unwrap(); + let mut other = credentials.clone(); + other.nonce = Nonce::random(); + assert!(matches!( + receipt.verify(&other).unwrap_err(), + KeyError::Signature(_), + )); +} + +#[test] +async fn receipt_from_proxy_key_is_refused() { + let (_tmp, pki) = test_pki("receipt-proxy-key"); + let (mut key, platform) = vouched_key(&pki, VoucherRole::Proxy); + let credentials = fake_credentials(&mut user_key()).await; + let receipt = LoginReceipt::new(&mut key, &platform, &credentials) + .await + .unwrap(); + assert!(matches!( + receipt.verify(&credentials).unwrap_err(), + KeyError::Signature(_), + )); +} + +/// Answer a fresh challenge from `mgr` by signing with `key`. +async fn answer_challenge(mgr: &JobManager, key: &mut EphemeralKey) -> (Credentials, RequestKey) { + let JobError::Unauthorized(nonce) = mgr.iam(None, ("POST", "/iam")).await.unwrap_err() else { + panic!("expected a challenge"); + }; + let request_key = RequestKey::new(); + let response = ChallengeResponse::new(Challenge::new(nonce), request_key.verifier()); + let verified = key + .sign(response) + .await + .unwrap() + .verify_with_ssh_public_key(&key.ssh_public_key()) + .unwrap(); + let mut credentials = Credentials::new(verified); + credentials.key_id = key.ssh_public_key().key_id().unwrap(); // override cert key ID + (credentials, request_key) +} + +/// Log in at `mgr` with `credentials`, presenting `receipt` if given. +async fn login( + mgr: &JobManager, + key: &EphemeralKey, + credentials: &Credentials, + receipt: Option, +) -> Result { + let request = IamRequest::new(key.ssh_public_key(), receipt); + mgr.login(Some(credentials.to_string()), request, ("POST", "/iam")) + .await +} + +/// Two servers: one that issues receipts, and one that accepts them. +/// The second links to the first only if `linked` is true. +async fn issuer_and_receiver( + test_name: &'static str, + linked: bool, +) -> (JobManager, JobManager, EphemeralKey, impl Sized) { + let log = test_logger(test_name); + let (tmp, pki) = test_pki("receipts"); + let issuer = Receipts::new( + &log, + pki_roots(&pki), + Some(vouched_key(&pki, VoucherRole::Server)), + ); + let (issuer, user, _, issuer_dir, issuer_shutdown) = + manager_with_receipts(log.clone(), LinkedBaseboards::lonely(), issuer).await; + let sleds = if linked { + BTreeSet::from([pki_baseboard()]) + } else { + BTreeSet::new() + }; + let receiver = Receipts::new(&log, pki_roots(&pki), None); + let (receiver, _, _, receiver_dir, receiver_shutdown) = + manager_with_receipts(log, LinkedBaseboards::fixed(sleds), receiver).await; + let guard = ( + tmp, + issuer_dir, + receiver_dir, + issuer_shutdown.drop_guard(), + receiver_shutdown.drop_guard(), + ); + (issuer, receiver, user, guard) +} + +#[named] +#[test] +async fn receipt_from_linked_sled_logs_in() { + let (issuer, receiver, mut user, _guard) = issuer_and_receiver(function_name!(), true).await; + let (credentials, request_key) = answer_challenge(&issuer, &mut user).await; + let receipt = login(&issuer, &user, &credentials, None) + .await + .unwrap() + .receipt + .expect("an issuing server returns a receipt"); + + let response = login(&receiver, &user, &credentials, Some(receipt)) + .await + .unwrap(); + assert_eq!(response.identity.nonce, credentials.nonce); + assert_eq!(response.receipt, None); + + let authz = Authz::new(credentials, request_key); + let header = authz.header("GET", "/sessions"); + receiver + .iam(Some(header), ("GET", "/sessions")) + .await + .unwrap(); +} + +#[named] +#[test] +async fn receipt_from_unlinked_sled_is_refused() { + let (issuer, receiver, mut user, _guard) = issuer_and_receiver(function_name!(), false).await; + let (credentials, _) = answer_challenge(&issuer, &mut user).await; + let receipt = login(&issuer, &user, &credentials, None) + .await + .unwrap() + .receipt; + assert!(matches!( + login(&receiver, &user, &credentials, receipt).await, + Err(JobError::Unauthorized(_)), + )); +} + +/// A receipt signed with a key that another rack's platform identity +/// vouches for fails the platform root check, though its signature is +/// valid. +#[named] +#[test] +async fn receipt_from_another_rack_is_refused() { + let log = test_logger(function_name!()); + let (_tmp, pki) = test_pki("receipt-rack"); + let (_other_tmp, other) = test_pki("receipt-other-rack"); + let (mut key, platform) = vouched_key(&other, VoucherRole::Server); + let credentials = fake_credentials(&mut user_key()).await; + let receipt = LoginReceipt::new(&mut key, &platform, &credentials) + .await + .unwrap(); + let receipts = Receipts::new(&log, pki_roots(&pki), None); + assert!(matches!( + receipts.verify(&receipt, &credentials).unwrap_err(), + ReceiptError::Chain(_), + )); +} + +/// Presenting a receipt for a login the server already holds leaves +/// that login's spent sequence numbers spent. +#[named] +#[test] +async fn presenting_again_keeps_spent_sequence_numbers() { + let (issuer, receiver, mut user, _guard) = issuer_and_receiver(function_name!(), true).await; + let (credentials, request_key) = answer_challenge(&issuer, &mut user).await; + let receipt = login(&issuer, &user, &credentials, None) + .await + .unwrap() + .receipt; + login(&receiver, &user, &credentials, receipt.clone()) + .await + .unwrap(); + + let authz = Authz::new(credentials.clone(), request_key); + let header = authz.header("GET", "/sessions"); + receiver + .iam(Some(header.clone()), ("GET", "/sessions")) + .await + .unwrap(); + login(&receiver, &user, &credentials, receipt) + .await + .unwrap(); + assert!(matches!( + receiver.iam(Some(header), ("GET", "/sessions")).await, + Err(JobError::Unauthorized(_)), + )); +} + +#[named] +#[test] +async fn server_refuses_receipt_for_another_login() { + let (issuer, receiver, mut user, _guard) = issuer_and_receiver(function_name!(), true).await; + let (first, _) = answer_challenge(&issuer, &mut user).await; + let receipt = login(&issuer, &user, &first, None).await.unwrap().receipt; + let (second, _) = answer_challenge(&issuer, &mut user).await; + assert!(matches!( + login(&receiver, &user, &second, receipt).await, + Err(JobError::Unauthorized(_)), + )); +} diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 4251caee..d22f8ce0 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -5,7 +5,7 @@ //! Integration test utilities. use std::convert::Infallible; -use std::fs::{read_to_string, write}; +use std::fs::{read, read_to_string, write}; use std::sync::OnceLock; use std::time::Duration; @@ -21,14 +21,18 @@ use rand_core::{OsRng, RngCore as _}; use sled_hardware_types::BaseboardId; use slog::{Drain as _, Logger, o}; use slog_term::{FullFormat, PlainSyncDecorator, TestStdoutWriter}; -use sprockets_tls_test_utils::{certlist_path, private_key_path, sprockets_auth_prefix}; +use sprockets_tls_test_utils::{ + cert_path, certlist_path, private_key_path, root_prefix, sprockets_auth_prefix, +}; use tempfile::TempDir; use tokio::sync::watch; use tokio_util::sync::CancellationToken; +use x509_cert::Certificate; +use x509_cert::der::DecodePem as _; use x509_cert::name::Name; use x509_cert::time::Validity; -use sush_api::{JobStartParams, JobWait}; +use sush_api::{IamRequest, JobStartParams, JobWait}; use sush_client::context::Authz; use sush_client::{Client, ResponseValue}; use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; @@ -39,10 +43,12 @@ use sush_common::jobs::{ }; use sush_common::keys::{EphemeralKey, KeyType, Signer, pem_cert_chain}; use sush_common::targets::{Cubbies, Target}; +use sush_common::vouchers::VoucherRole; use sush_server::executor::PathIsolation; use sush_server::gossip::{LinkedBaseboards, Universe}; use sush_server::locker::Locker; use sush_server::output::{JobOutputDir, JobOutputFileStream}; +use sush_server::receipts::Receipts; use sush_server::state::GossipNetwork; use sush_server::{JobManager, seed_gossip}; @@ -194,11 +200,31 @@ pub fn test_pki(prefix: &'static str) -> (TempDir, Utf8PathBuf) { /// A fresh proxy key vouched for by the test PKI's platform identity, /// written under `pki` as the key and chain PEMs `platform_tls` takes. pub fn vouched_proxy_pems(pki: &Utf8PathBuf) -> (Utf8PathBuf, Utf8PathBuf) { + let (signer_pem, chain_pem) = platform_identity_pems(pki); + crafted_proxy_pems(pki, &signer_pem, &chain_pem) +} + +/// The platform roots of `pki`. +pub fn pki_roots(pki: &Utf8PathBuf) -> Vec { + let pem = read(cert_path(pki.clone(), &root_prefix())).unwrap(); + vec![Certificate::from_pem(&pem).unwrap()] +} + +/// The baseboard named by the test PKI's platform identity. +pub fn pki_baseboard() -> BaseboardId { + BaseboardId { + part_number: "913-0000019".to_string(), + serial_number: "2FAKE001".to_string(), + } +} + +/// The test PKI's platform identity key and chain, as PEM. +fn platform_identity_pems(pki: &Utf8PathBuf) -> (String, String) { let signer_pem = read_to_string(private_key_path(pki.clone(), &sprockets_auth_prefix(1))) .expect("can't read the platform key"); let chain_pem = read_to_string(certlist_path(pki.clone(), &sprockets_auth_prefix(1))) .expect("can't read the platform chain"); - crafted_proxy_pems(pki, &signer_pem, &chain_pem) + (signer_pem, chain_pem) } /// A fresh proxy key vouched for by the `signer_pem` key and served @@ -211,6 +237,7 @@ pub fn crafted_proxy_pems( ) -> (Utf8PathBuf, Utf8PathBuf) { let signer = SigningKey::from_pkcs8_pem(signer_pem).expect("can't parse the vouching key"); let vouched = EphemeralKey::new_vouched( + VoucherRole::Proxy, KeyType::Ed25519, "CN=sush-proxy".parse().unwrap(), Validity::from_now(Duration::from_secs(600)).unwrap(), @@ -225,6 +252,35 @@ pub fn crafted_proxy_pems( (key_path, chain_path) } +/// A fresh key vouched for `role` by the test PKI's platform identity, +/// and that identity's chain. +pub fn vouched_key(pki: &Utf8PathBuf, role: VoucherRole) -> (EphemeralKey, Vec) { + let (signer_pem, chain_pem) = platform_identity_pems(pki); + let signer = SigningKey::from_pkcs8_pem(&signer_pem).expect("can't parse the vouching key"); + let vouched = EphemeralKey::new_vouched( + role, + KeyType::Ed25519, + "CN=sush-server".parse().unwrap(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + |digest| Ok::<_, Infallible>(signer.sign(digest).to_vec()), + ) + .expect("can't generate a vouched cert"); + let platform = + Certificate::load_pem_chain(chain_pem.as_bytes()).expect("can't parse the platform chain"); + (vouched, platform) +} + +/// Login credentials for `key`, as a client presents them to `/iam`. +pub async fn fake_credentials(key: &mut EphemeralKey) -> Credentials { + 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(); + Credentials::new(verified) +} + pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { let nonce = Nonce::random(); let challenge = Challenge::new(nonce.clone()); @@ -334,6 +390,22 @@ pub async fn manager_test_root_and_peer( GossipNetwork, TempDir, CancellationToken, +) { + manager_with_receipts(log, LinkedBaseboards::lonely(), Receipts::none()).await +} + +/// Like [`manager_test_root_and_peer`], but with the given linked sleds +/// and receipt handling. +pub async fn manager_with_receipts( + log: Logger, + linked: LinkedBaseboards, + receipts: Receipts, +) -> ( + JobManager, + EphemeralKey, + GossipNetwork, + TempDir, + CancellationToken, ) { let dir = TempDir::with_prefix("sush-").unwrap(); let seed = null_gossip().await; @@ -348,7 +420,8 @@ pub async fn manager_test_root_and_peer( test_baseboard_id(), no_cubbies(), gossip, - LinkedBaseboards::lonely(), + linked, + receipts, &Locker::null(), &[root.cert().to_owned()], shutdown.clone(), @@ -394,10 +467,11 @@ pub async fn authz( let identity = client .iam() .authorization(credentials.to_string()) - .body(public_key.to_string()) + .body(IamRequest::from(public_key)) .send() .await .unwrap() - .into_inner(); + .into_inner() + .identity; (identity, Authz::new(credentials, request_key)) } diff --git a/tests/src/tls_tests.rs b/tests/src/tls_tests.rs index 647cd583..1f9dde02 100644 --- a/tests/src/tls_tests.rs +++ b/tests/src/tls_tests.rs @@ -7,7 +7,7 @@ //! the vouchers that verify against its roots; see RFD 620 §4.6.2.1. use std::collections::BTreeMap; -use std::fs::{read, read_to_string}; +use std::fs::read_to_string; use std::net::SocketAddr; use std::sync::Arc; @@ -16,7 +16,7 @@ use dropshot::{ConfigDropshot, HttpServer, ServerBuilder}; use function_name::named; use slog::Logger; use sprockets_tls_test_utils::{ - cert_path, certlist_path, device_id_prefix, platform_id_prefix, private_key_path, root_prefix, + cert_path, certlist_path, device_id_prefix, platform_id_prefix, private_key_path, signer_prefix, sprockets_auth_prefix, }; use tempfile::TempDir; @@ -24,8 +24,8 @@ use tokio::sync::watch; use tokio::test; use tokio_util::sync::CancellationToken; use x509_cert::Certificate; -use x509_cert::der::DecodePem as _; +use sush_api::IamRequest; use sush_api::sush_api_mod::api_description; use sush_client::tls::client as tls_client; use sush_client::{AuthzSigner, Client, Error as ClientError}; @@ -35,8 +35,8 @@ use sush_server::proxy::{Sleds, Targets, platform_tls}; use sush_server::{ApiServer, JobManager, ProxyServer}; use crate::test_utils::{ - authz, crafted_proxy_pems, manager_and_test_root, test_baseboard_id, test_logger, test_pki, - vouched_proxy_pems, + authz, crafted_proxy_pems, manager_and_test_root, pki_roots, test_baseboard_id, test_logger, + test_pki, vouched_proxy_pems, }; fn local_addr() -> SocketAddr { @@ -98,12 +98,6 @@ impl ProxiedServer { } } -/// The root cert of `pki`, as the platform roots a client takes. -fn pki_roots(pki: &Utf8PathBuf) -> Vec { - let pem = read(cert_path(pki.clone(), &root_prefix())).unwrap(); - vec![Certificate::from_pem(&pem).unwrap()] -} - /// A client rooted at `roots` must fail its handshake with `url`. /// Only a transport-level error counts: an HTTP response, even an /// error, means the handshake succeeded. @@ -113,7 +107,7 @@ async fn refuse_handshake(url: &str, roots: Vec) { tls_client(roots, None).unwrap(), AuthzSigner::default(), ); - match client.iam().body(None).send().await { + match client.iam().body(IamRequest::default()).send().await { Err(ClientError::CommunicationError(_)) => (), Err(other) => panic!("expected a refused handshake: {other}"), Ok(_) => panic!("expected a refused handshake, request succeeded"), @@ -136,7 +130,12 @@ async fn client_tls_proxy_server() { tls_client(pki_roots(&pki), None).unwrap(), signer.clone(), ); - let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + let ClientError::ErrorResponse(unauthz) = client + .iam() + .body(IamRequest::default()) + .send() + .await + .unwrap_err() else { panic!("expected error response") }; @@ -145,12 +144,12 @@ async fn client_tls_proxy_server() { signer.set(Some(credentials)); let iam = client .iam() - .body(None) + .body(IamRequest::default()) .send() .await .expect("can't authenticate") .into_inner(); - assert_eq!(iam, identity, "who am I?"); + assert_eq!(iam.identity, identity, "who am I?"); proxied.stop().await; }