lazaret/crates/lazaret-control-client/src/lib.rs

411 lines
15 KiB
Rust

#![doc = "Dependency-neutral client side of the Lazaret control protocol."]
use std::fmt;
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::ConfinedPathPlan;
use lazaret_core::identity::{CertificateDigest, ClientId};
use lazaret_core::protocol::{
ProtocolValidationError, client_enrollment_binding, verify_tls_credential,
};
use lazaret_core::records::{
ClientEnrollmentRequest, GenerationIdentity, ProtocolPublicationIdentity, ProtocolVersion,
TlsCredential, TlsRole,
};
use rcgen::{
CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair,
KeyUsagePurpose, PKCS_ED25519,
};
use rustix::fs::{Mode, OFlags, openat};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
use rustls::{ClientConfig, ClientConnection, RootCertStore};
use sha2::{Digest, Sha256};
pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1";
pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid";
pub const CONTROL_SOCKET_NAME: &str = "c.sock";
pub const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard";
pub const MAX_CONTROL_BYTES: usize = 65_536;
#[derive(Debug)]
pub enum ControlClientError {
Io(io::Error),
Codec(CodecError),
Certificate(rcgen::Error),
Tls(rustls::Error),
Protocol(ProtocolValidationError),
InvalidPath(&'static str),
InvalidPublication(&'static str),
InvalidCertificateProfile,
OversizeFrame { length: usize, maximum: usize },
}
impl fmt::Display for ControlClientError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "control I/O failed: {error}"),
Self::Codec(error) => write!(formatter, "control record failed validation: {error}"),
Self::Certificate(error) => write!(formatter, "certificate operation failed: {error}"),
Self::Tls(error) => write!(formatter, "TLS failed: {error}"),
Self::Protocol(error) => write!(formatter, "protocol validation failed: {error}"),
Self::InvalidPath(reason) => write!(formatter, "invalid control path: {reason}"),
Self::InvalidPublication(reason) => {
write!(formatter, "invalid control publication: {reason}")
}
Self::InvalidCertificateProfile => {
formatter.write_str("TLS certificate profile is invalid")
}
Self::OversizeFrame { length, maximum } => {
write!(formatter, "control frame length {length} exceeds {maximum}")
}
}
}
}
impl std::error::Error for ControlClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Codec(error) => Some(error),
Self::Certificate(error) => Some(error),
Self::Tls(error) => Some(error),
Self::Protocol(error) => Some(error),
Self::InvalidPath(_)
| Self::InvalidPublication(_)
| Self::InvalidCertificateProfile
| Self::OversizeFrame { .. } => None,
}
}
}
impl From<io::Error> for ControlClientError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<CodecError> for ControlClientError {
fn from(error: CodecError) -> Self {
Self::Codec(error)
}
}
impl From<rcgen::Error> for ControlClientError {
fn from(error: rcgen::Error) -> Self {
Self::Certificate(error)
}
}
impl From<rustls::Error> for ControlClientError {
fn from(error: rustls::Error) -> Self {
Self::Tls(error)
}
}
impl From<ProtocolValidationError> for ControlClientError {
fn from(error: ProtocolValidationError) -> Self {
Self::Protocol(error)
}
}
pub struct PendingClientEnrollment {
request: ClientEnrollmentRequest,
private_key: PrivatePkcs8KeyDer<'static>,
}
impl PendingClientEnrollment {
/// Creates a CSR bound to the exact generation and Client CLI identity.
///
/// # Errors
/// Returns an error when encoding, key generation, or CSR signing fails.
pub fn create(
generation_identity: GenerationIdentity,
cli_id: ClientId,
) -> Result<Self, ControlClientError> {
let binding = client_enrollment_binding(&generation_identity, cli_id)?;
let mut params = CertificateParams::default();
params.distinguished_name = DistinguishedName::new();
params.distinguished_name.push(DnType::CommonName, binding);
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
let key = KeyPair::generate_for(&PKCS_ED25519)?;
let csr = params.serialize_request(&key)?;
Ok(Self {
request: ClientEnrollmentRequest {
protocol_version: ProtocolVersion::V1,
generation_identity,
cli_id,
certificate_signing_request_der: csr.der().to_vec(),
},
private_key: PrivatePkcs8KeyDer::from(key.serialize_der()),
})
}
#[must_use]
pub const fn request(&self) -> &ClientEnrollmentRequest {
&self.request
}
#[must_use]
pub fn private_key(&self) -> PrivateKeyDer<'static> {
PrivateKeyDer::Pkcs8(self.private_key.clone_key())
}
}
pub struct ControlClient;
impl ControlClient {
/// Discovers a publication through retained Runtime Domain authority.
///
/// # Errors
/// Refuses changed authority and malformed or non-private publication objects.
pub fn discover_confined(
runtime_domain_root: &ConfinedPathPlan,
uid: u32,
) -> Result<(File, PathBuf, ProtocolPublicationIdentity), ControlClientError> {
let root =
File::from(runtime_domain_root.open_authority().map_err(|_| {
ControlClientError::InvalidPath("runtime Domain authority changed")
})?);
let root_metadata = root.metadata()?;
if !root_metadata.is_dir()
|| root_metadata.uid() != uid
|| root_metadata.mode() & 0o7777 != 0o700
{
return Err(ControlClientError::InvalidPath(
"runtime Domain root is not invoking-user-owned mode 0700",
));
}
let mut identity = File::from(
openat(
root.as_fd(),
PUBLICATION_IDENTITY_NAME,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?,
);
let metadata = identity.metadata()?;
if !metadata.file_type().is_file()
|| metadata.uid() != uid
|| metadata.mode() & 0o7777 != 0o600
|| metadata.nlink() != 1
|| metadata.len() > MAX_CONTROL_BYTES as u64
{
return Err(ControlClientError::InvalidPublication(
"publication identity is not an exact private regular file",
));
}
let socket = File::from(
openat(
root.as_fd(),
CONTROL_SOCKET_NAME,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?,
);
let metadata = socket.metadata()?;
if !metadata.file_type().is_socket()
|| metadata.uid() != uid
|| metadata.mode() & 0o7777 != 0o600
{
return Err(ControlClientError::InvalidPublication(
"control socket is not an invoking-user-private socket",
));
}
let mut bytes = Vec::new();
identity.read_to_end(&mut bytes)?;
let socket_path = PathBuf::from(format!(
"/proc/self/fd/{}/{}",
root.as_raw_fd(),
CONTROL_SOCKET_NAME
));
Ok((root, socket_path, decode(&bytes)?))
}
/// Builds a TLS 1.3 client configuration pinned to the publication CA.
///
/// # Errors
/// Refuses malformed CA or client credential material.
pub fn config(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
let mut roots = RootCertStore::empty();
roots.add(CertificateDer::from(
publication.tls_ca_certificate_der.clone(),
))?;
let builder = ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_root_certificates(roots);
let mut config = if let Some((credential, private_key)) = client {
verify_tls_credential(
credential,
&publication.generation_identity,
TlsRole::ClientCli,
credential.subject_id(),
)?;
builder.with_client_auth_cert(
vec![CertificateDer::from(credential.certificate_der.clone())],
private_key,
)?
} else {
builder.with_no_client_auth()
};
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
config.enable_early_data = false;
config.resumption = rustls::client::Resumption::disabled();
Ok(config)
}
/// Connects a pinned TLS stream to the control socket.
///
/// # Errors
/// Returns socket, TLS, or certificate-profile failures.
pub fn connect(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
let socket = std::os::unix::net::UnixStream::connect(socket_path)?;
let server_name = ServerName::try_from(CONTROL_SERVER_NAME)
.map_err(|_| ControlClientError::InvalidCertificateProfile)?;
let mut connection = ClientConnection::new(Arc::new(config), server_name)?;
let mut socket = socket;
while connection.is_handshaking() {
connection.complete_io(&mut socket)?;
}
let [certificate] = connection.peer_certificates().unwrap_or_default() else {
return Err(ControlClientError::InvalidCertificateProfile);
};
if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3)
|| connection.alpn_protocol() != Some(CONTROL_ALPN)
|| certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256
{
return Err(ControlClientError::InvalidCertificateProfile);
}
Ok(rustls::StreamOwned::new(connection, socket))
}
/// Writes one bounded length-delimited control record.
///
/// # Errors
/// Returns encoding, size, or stream failures.
pub fn send<T: serde::Serialize>(
stream: &mut impl Write,
record: &T,
) -> Result<(), ControlClientError> {
let bytes = encode(record)?;
if bytes.len() > MAX_CONTROL_BYTES {
return Err(ControlClientError::OversizeFrame {
length: bytes.len(),
maximum: MAX_CONTROL_BYTES,
});
}
let length = u32::try_from(bytes.len()).map_err(|_| ControlClientError::OversizeFrame {
length: bytes.len(),
maximum: MAX_CONTROL_BYTES,
})?;
stream.write_all(&length.to_be_bytes())?;
stream.write_all(&bytes)?;
stream.flush()?;
Ok(())
}
/// Reads one bounded length-delimited control record.
///
/// # Errors
/// Returns framing, size, decoding, or stream failures.
pub fn receive<T>(stream: &mut impl Read) -> Result<T, ControlClientError>
where
T: serde::de::DeserializeOwned + serde::Serialize,
{
let mut length = [0_u8; 4];
stream.read_exact(&mut length)?;
let length = u32::from_be_bytes(length) as usize;
if length > MAX_CONTROL_BYTES {
return Err(ControlClientError::OversizeFrame {
length,
maximum: MAX_CONTROL_BYTES,
});
}
let mut bytes = vec![0_u8; length];
stream.read_exact(&mut bytes)?;
Ok(decode(&bytes)?)
}
}
fn certificate_digest(certificate: impl AsRef<[u8]>) -> CertificateDigest {
CertificateDigest::new(sha256(certificate.as_ref()))
}
fn sha256(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[cfg(test)]
mod tests {
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, SandboxId,
};
use lazaret_core::records::{ControlRequest, GenerationIdentity};
use super::*;
#[test]
fn enrollment_request_retains_exact_generation_and_client_identity() {
let generation = GenerationIdentity {
agent_profile: "profile".to_owned(),
domain_id: DomainId::new([1; 32]),
generation_id: GenerationId::new(2),
configuration_fingerprint: ConfigurationFingerprint::new([3; 32]),
coordinator_id: CoordinatorId::new([4; 32]),
sandbox_id: SandboxId::new([5; 32]),
};
let cli_id = ClientId::new([6; 32]);
let pending = PendingClientEnrollment::create(generation.clone(), cli_id).unwrap();
assert_eq!(pending.request().generation_identity, generation);
assert_eq!(pending.request().cli_id, cli_id);
assert!(!pending.request().certificate_signing_request_der.is_empty());
assert!(matches!(pending.private_key(), PrivateKeyDer::Pkcs8(_)));
}
#[test]
fn control_framing_round_trips_and_rejects_oversize_length() {
let request = ControlRequest::Enroll(
PendingClientEnrollment::create(
GenerationIdentity {
agent_profile: "profile".to_owned(),
domain_id: DomainId::new([7; 32]),
generation_id: GenerationId::new(8),
configuration_fingerprint: ConfigurationFingerprint::new([9; 32]),
coordinator_id: CoordinatorId::new([10; 32]),
sandbox_id: SandboxId::new([11; 32]),
},
ClientId::new([12; 32]),
)
.unwrap()
.request()
.clone(),
);
let mut bytes = Vec::new();
ControlClient::send(&mut bytes, &request).unwrap();
assert_eq!(
ControlClient::receive::<ControlRequest>(&mut bytes.as_slice()).unwrap(),
request
);
let oversized = u32::try_from(MAX_CONTROL_BYTES + 1).unwrap().to_be_bytes();
assert!(ControlClient::receive::<ControlRequest>(&mut oversized.as_slice()).is_err());
}
}