lazaret/crates/lazaret-host-coordinator/tests/control.rs

710 lines
24 KiB
Rust

use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread::{self, JoinHandle};
use ed25519_dalek::SigningKey;
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
NetworkSidecarId, SandboxId, SessionId,
};
use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, Domain, DurableDecision, Generation,
GenerationIdentity, ProtocolVersion, SessionBinding, SessionPhase, SessionRegistrationRequest,
SessionRegistrationResponse, TlsCredential,
};
use lazaret_host_coordinator::control::{
CONTROL_ALPN, ControlError, PendingClientEnrollment, PublishedControlFace, SessionRegistry,
StagedControlFace, TrustAnchor,
};
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
use lazaret_linux::filesystem::try_exclusive_flock;
use rustix::process::getuid;
use x509_parser::extensions::GeneralName;
use x509_parser::parse_x509_certificate;
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lazaret-control-{label}-{}-{sequence}",
std::process::id()
));
std::fs::create_dir(&path).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
fn generation_identity(generation: u64) -> GenerationIdentity {
GenerationIdentity {
agent_profile: "trusted".into(),
domain_id: DomainId::new(bytes(1)),
generation_id: GenerationId::new(generation),
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
coordinator_id: CoordinatorId::new(bytes(u8::try_from(generation + 10).unwrap())),
sandbox_id: SandboxId::new(bytes(u8::try_from(generation + 20).unwrap())),
}
}
fn domain() -> Domain {
Domain {
domain_id: DomainId::new(bytes(1)),
profile_name: "trusted".into(),
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
}
}
fn generation(identity: &GenerationIdentity, decision: DurableDecision) -> Generation {
Generation {
domain_id: identity.domain_id,
generation_id: identity.generation_id,
configuration_fingerprint: identity.configuration_fingerprint,
coordinator_id: identity.coordinator_id,
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
sandbox_id: identity.sandbox_id,
durable_decision: decision,
}
}
fn open_store(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> Arc<HostCoordinatorDomainStore> {
let lock_path = directory.path("domain.lock");
let lock = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(lock_path)
.unwrap();
let lock = try_exclusive_flock(lock).unwrap();
let store = Arc::new(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap(),
);
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::None,
))])
.unwrap();
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::Preparing,
))])
.unwrap();
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::Published,
))])
.unwrap();
store
}
fn open_preparing_store(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> Arc<HostCoordinatorDomainStore> {
let lock = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(directory.path("domain.lock"))
.unwrap();
let store = Arc::new(
HostCoordinatorDomainStore::open(
try_exclusive_flock(lock).unwrap(),
&directory.path("domain.redb"),
&domain(),
)
.unwrap(),
);
for decision in [DurableDecision::None, DurableDecision::Preparing] {
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity, decision,
))])
.unwrap();
}
store
}
fn publish(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> (Arc<PublishedControlFace>, Arc<HostCoordinatorDomainStore>) {
let store = open_store(directory, identity);
let face = PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
Arc::clone(&store),
)
.unwrap();
(Arc::new(face), store)
}
fn server(face: &Arc<PublishedControlFace>) -> JoinHandle<Result<(), ControlError>> {
let face = Arc::clone(face);
thread::spawn(move || face.serve_next())
}
struct EnrolledClient {
pending: PendingClientEnrollment,
credential: TlsCredential,
server_certificate: Vec<u8>,
}
fn enroll(face: &Arc<PublishedControlFace>, cli_id: ClientId) -> EnrolledClient {
let pending = PendingClientEnrollment::create(
face.publication_identity().generation_identity.clone(),
cli_id,
)
.unwrap();
let serving = server(face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(pending.request().clone()),
)
.unwrap();
let response: ControlResponse = PublishedControlFace::receive(&mut stream).unwrap();
assert_eq!(
stream.conn.protocol_version(),
Some(rustls::ProtocolVersion::TLSv1_3)
);
assert_eq!(stream.conn.alpn_protocol(), Some(CONTROL_ALPN));
let server_certificate = stream.conn.peer_certificates().unwrap()[0].to_vec();
drop(stream);
serving.join().unwrap().unwrap();
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) = response else {
panic!("unexpected enrollment response")
};
EnrolledClient {
pending,
credential,
server_certificate,
}
}
fn assert_certificate_profiles(face: &PublishedControlFace, client: &EnrolledClient) {
let (_, ca) =
parse_x509_certificate(&face.publication_identity().tls_ca_certificate_der).unwrap();
assert!(ca.is_ca());
assert!(ca.key_usage().unwrap().unwrap().value.key_cert_sign());
assert_eq!(
ca.signature_algorithm.algorithm.to_id_string(),
"1.3.101.112"
);
let (_, server_certificate) = parse_x509_certificate(&client.server_certificate).unwrap();
let server_usage = server_certificate.extended_key_usage().unwrap().unwrap();
assert!(server_usage.value.server_auth);
assert!(!server_usage.value.client_auth);
let server_names = server_certificate
.subject_alternative_name()
.unwrap()
.unwrap();
assert!(
server_names
.value
.general_names
.iter()
.any(|name| { matches!(name, GeneralName::DNSName("coordinator.lazaret.invalid")) })
);
assert!(
server_certificate.validity().not_after.timestamp()
- server_certificate.validity().not_before.timestamp()
<= 3_660
);
let (_, client_certificate) =
parse_x509_certificate(&client.credential.certificate_der).unwrap();
let client_usage = client_certificate.extended_key_usage().unwrap().unwrap();
assert!(client_usage.value.client_auth);
assert!(!client_usage.value.server_auth);
assert_eq!(
client_certificate
.signature_algorithm
.algorithm
.to_id_string(),
"1.3.101.112"
);
assert!(
client_certificate.validity().not_after.timestamp()
- client_certificate.validity().not_before.timestamp()
<= 3_660
);
}
fn registration_binding(
identity: &GenerationIdentity,
cli_id: ClientId,
session: u8,
anchor: u8,
) -> SessionBinding {
SessionBinding {
generation_identity: identity.clone(),
cli_id,
session_id: SessionId::new(bytes(session)),
session_anchor_public_key: SigningKey::from_bytes(&bytes(anchor))
.verifying_key()
.to_bytes()
.to_vec(),
}
}
fn open_registration(
face: &Arc<PublishedControlFace>,
client: &EnrolledClient,
binding: SessionBinding,
) -> (
rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
JoinHandle<Result<(), ControlError>>,
SessionRegistrationResponse,
) {
let serving = server(face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding,
}),
)
.unwrap();
let response: ControlResponse = PublishedControlFace::receive(&mut stream).unwrap();
let ControlResponse::Registered(response) = response else {
panic!("unexpected registration response")
};
(stream, serving, response)
}
fn refused_registration(
face: &Arc<PublishedControlFace>,
client: &EnrolledClient,
binding: SessionBinding,
) -> ControlError {
let serving = server(face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding,
}),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
serving.join().unwrap().unwrap_err()
}
#[test]
fn real_uds_tls_enrollment_and_registration_preserve_exact_profiles_and_session_state() {
let directory = TestDirectory::new("complete");
let identity = generation_identity(7);
let (face, store) = publish(&directory, &identity);
let socket_metadata = std::fs::symlink_metadata(face.socket_path()).unwrap();
let identity_metadata = std::fs::symlink_metadata(face.identity_path()).unwrap();
assert_eq!(socket_metadata.uid(), getuid().as_raw());
assert_eq!(socket_metadata.mode() & 0o7777, 0o600);
assert_eq!(identity_metadata.uid(), getuid().as_raw());
assert_eq!(identity_metadata.mode() & 0o7777, 0o600);
assert_eq!(face.socket_path().file_name().unwrap(), "control.sock");
assert_eq!(
face.identity_path().file_name().unwrap(),
"protocol-identity.postcard"
);
let client = enroll(&face, ClientId::new(bytes(30)));
assert_certificate_profiles(&face, &client);
let mut wrong_leaf_pin = face.publication_identity().clone();
wrong_leaf_pin.coordinator_tls_certificate_sha256 =
lazaret_core::identity::CertificateDigest::new(bytes(99));
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(PublishedControlFace::connect(face.socket_path(), config, &wrong_leaf_pin).is_err());
assert!(serving.join().unwrap().is_err());
let serving = server(&face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(client.pending.request().clone()),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidRequest
));
let binding = registration_binding(&identity, ClientId::new(bytes(30)), 31, 32);
let (stream, serving, response) = open_registration(&face, &client, binding.clone());
assert_eq!(response.session_capability.session_binding, binding);
assert_eq!(face.registry().connected_sessions().unwrap().len(), 1);
assert_eq!(
store.snapshot().unwrap().sessions[0].session_phase,
SessionPhase::Attached
);
drop(stream);
serving.join().unwrap().unwrap();
assert!(face.registry().connected_sessions().unwrap().is_empty());
assert_eq!(
store.snapshot().unwrap().sessions[0].session_phase,
SessionPhase::Closed
);
}
#[test]
fn duplicate_session_cli_and_anchor_identities_are_refused_while_registered() {
let directory = TestDirectory::new("duplicates");
let identity = generation_identity(7);
let (face, _) = publish(&directory, &identity);
let first = enroll(&face, ClientId::new(bytes(40)));
let second = enroll(&face, ClientId::new(bytes(41)));
let mismatched_cli = registration_binding(&identity, ClientId::new(bytes(41)), 39, 40);
assert!(matches!(
refused_registration(&face, &first, mismatched_cli),
ControlError::InvalidCertificateProfile
));
let first_binding = registration_binding(&identity, ClientId::new(bytes(40)), 42, 43);
let (first_stream, first_server, _) = open_registration(&face, &first, first_binding.clone());
let duplicate_session = registration_binding(&identity, ClientId::new(bytes(41)), 42, 44);
assert!(matches!(
refused_registration(&face, &second, duplicate_session),
ControlError::DuplicateIdentity
));
let duplicate_cli = registration_binding(&identity, ClientId::new(bytes(40)), 45, 46);
assert!(matches!(
refused_registration(&face, &first, duplicate_cli),
ControlError::DuplicateIdentity
));
let duplicate_anchor = SessionBinding {
generation_identity: identity,
cli_id: ClientId::new(bytes(41)),
session_id: SessionId::new(bytes(47)),
session_anchor_public_key: first_binding.session_anchor_public_key,
};
assert!(matches!(
refused_registration(&face, &second, duplicate_anchor),
ControlError::DuplicateIdentity
));
drop(first_stream);
first_server.join().unwrap().unwrap();
}
#[test]
fn anonymous_connections_are_enrollment_only_bounded_and_exact_alpn_is_required() {
let directory = TestDirectory::new("anonymous");
let identity = generation_identity(7);
let (face, _) = publish(&directory, &identity);
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: registration_binding(&identity, ClientId::new(bytes(48)), 49, 50),
}),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidRequest
));
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
stream.write_all(&8_193_u32.to_be_bytes()).unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::OversizeRequest {
length: 8_193,
maximum: 8_192
}
));
let mut tampered = PendingClientEnrollment::create(identity.clone(), ClientId::new(bytes(51)))
.unwrap()
.request()
.clone();
*tampered.certificate_signing_request_der.last_mut().unwrap() ^= 1;
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(&mut stream, &ControlRequest::Enroll(tampered)).unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::Certificate(_)
));
let serving = server(&face);
let mut config =
PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
config.alpn_protocols = vec![b"not-lazaret".to_vec()];
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(serving.join().unwrap().is_err());
}
#[test]
fn stale_generation_and_publication_identity_are_refused() {
let directory = TestDirectory::new("stale");
let identity = generation_identity(7);
let (face, store) = publish(&directory, &identity);
let stale =
PendingClientEnrollment::create(generation_identity(6), ClientId::new(bytes(60))).unwrap();
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(stale.request().clone()),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::StaleGeneration
));
let later = generation_identity(8);
for decision in [
DurableDecision::None,
DurableDecision::Preparing,
DurableDecision::Published,
] {
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
&later, decision,
))])
.unwrap();
}
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::StaleGeneration
));
}
#[test]
fn host_coordinator_key_loss_never_reuses_old_tls_authority() {
let directory = TestDirectory::new("key-loss");
let identity = generation_identity(7);
let store = open_store(&directory, &identity);
let old_face = Arc::new(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
Arc::clone(&store),
)
.unwrap(),
);
let old_client = enroll(&old_face, ClientId::new(bytes(70)));
let old_publication = old_face.publication_identity().clone();
drop(old_face);
let new_face = Arc::new(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity).unwrap(),
store,
)
.unwrap(),
);
assert_ne!(
new_face.publication_identity().tls_ca_certificate_der,
old_publication.tls_ca_certificate_der
);
let serving = server(&new_face);
let old_config = PublishedControlFace::client_config(
&old_publication,
Some((&old_client.credential, old_client.pending.private_key())),
)
.unwrap();
assert!(
PublishedControlFace::connect(new_face.socket_path(), old_config, &old_publication,)
.is_err()
);
assert!(serving.join().unwrap().is_err());
}
#[test]
fn publication_refuses_wrong_root_path_mode_and_stale_identity_file() {
let mode_directory = TestDirectory::new("mode");
std::fs::set_permissions(&mode_directory.0, std::fs::Permissions::from_mode(0o755)).unwrap();
let identity = generation_identity(7);
let store = open_store(&mode_directory, &identity);
assert!(matches!(
PublishedControlFace::publish(
&mode_directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
store,
),
Err(ControlError::InvalidPath(_))
));
let symlink_parent = TestDirectory::new("symlink-parent");
let target = symlink_parent.path("target");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = symlink_parent.path("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let store_directory = TestDirectory::new("symlink-store");
let store = open_store(&store_directory, &identity);
assert!(matches!(
PublishedControlFace::publish(
&link,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
store,
),
Err(ControlError::InvalidPath(_))
));
let stale_directory = TestDirectory::new("stale-file");
let (face, _) = publish(&stale_directory, &identity);
let mut identity_file = OpenOptions::new()
.write(true)
.truncate(true)
.open(face.identity_path())
.unwrap();
identity_file.write_all(b"stale publication").unwrap();
identity_file.sync_all().unwrap();
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidPublication(_)
));
}
#[test]
fn publication_refuses_preexisting_fixed_control_path() {
let directory = TestDirectory::new("existing");
let identity = generation_identity(7);
let store = open_store(&directory, &identity);
File::create(directory.path("control.sock")).unwrap();
assert!(matches!(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity).unwrap(),
store,
),
Err(ControlError::InvalidPublication(_))
));
}
#[test]
fn prepared_control_face_admits_only_after_generation_publication() {
let directory = TestDirectory::new("publication-gate");
let identity = generation_identity(7);
let store = open_preparing_store(&directory, &identity);
let registry = Arc::new(SessionRegistry::new(Arc::clone(&store), identity.clone()));
let staged = StagedControlFace::stage(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
registry,
)
.unwrap();
assert!(std::os::unix::net::UnixStream::connect(directory.path("control.sock")).is_err());
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
&identity,
DurableDecision::Published,
))])
.unwrap();
let face = Arc::new(staged.expose().unwrap());
let client = enroll(&face, ClientId::new(bytes(90)));
assert_eq!(client.credential.generation_identity, identity);
}