Add confined host escalation transport
This commit is contained in:
parent
903ff837d6
commit
407b75b1ff
3 changed files with 2888 additions and 21 deletions
|
|
@ -10,7 +10,12 @@ name = "lazaret-escalation-client"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ed25519-dalek.workspace = true
|
||||
getrandom.workspace = true
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
rustix.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
253
crates/lazaret-escalation-client/src/lib.rs
Normal file
253
crates/lazaret-escalation-client/src/lib.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::identity::{RequestDigest, RequestId};
|
||||
use lazaret_core::protocol::{
|
||||
MAX_SIGNED_RECORD_BYTES, ProtocolCodecError, ProtocolValidationError, ResultCollector,
|
||||
ResultWindowError, VerifiedSession, acknowledgment_file_name, cancellation_file_name,
|
||||
encode_signed, request_file_name, result_chunk_file_name, result_file_name,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
MailboxCancellation, MailboxRequest, MailboxRequestError, MailboxResult, MailboxResultAck,
|
||||
ProtocolVersion, ResultStream,
|
||||
};
|
||||
use lazaret_linux::filesystem::RegularFileMailbox;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Io(io::Error),
|
||||
Request(MailboxRequestError),
|
||||
Protocol(ProtocolValidationError),
|
||||
ProtocolCodec(ProtocolCodecError),
|
||||
Window(ResultWindowError),
|
||||
Random(getrandom::Error),
|
||||
TerminalAlreadyReceived,
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => error.fmt(formatter),
|
||||
Self::Request(error) => error.fmt(formatter),
|
||||
Self::Protocol(error) => error.fmt(formatter),
|
||||
Self::ProtocolCodec(error) => error.fmt(formatter),
|
||||
Self::Window(error) => error.fmt(formatter),
|
||||
Self::Random(error) => error.fmt(formatter),
|
||||
Self::TerminalAlreadyReceived => {
|
||||
formatter.write_str("escalation request already received a terminal result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::Request(error) => Some(error),
|
||||
Self::Protocol(error) => Some(error),
|
||||
Self::ProtocolCodec(error) => Some(error),
|
||||
Self::Window(error) => Some(error),
|
||||
Self::Random(error) => Some(error),
|
||||
Self::TerminalAlreadyReceived => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! from_error {
|
||||
($source:ty, $variant:ident) => {
|
||||
impl From<$source> for ClientError {
|
||||
fn from(error: $source) -> Self {
|
||||
Self::$variant(error)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
from_error!(io::Error, Io);
|
||||
from_error!(MailboxRequestError, Request);
|
||||
from_error!(ProtocolValidationError, Protocol);
|
||||
from_error!(ProtocolCodecError, ProtocolCodec);
|
||||
from_error!(ResultWindowError, Window);
|
||||
from_error!(getrandom::Error, Random);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ClientEvent {
|
||||
Output {
|
||||
sequence: u64,
|
||||
stream: ResultStream,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
Terminal(Box<MailboxResult>),
|
||||
Pending,
|
||||
}
|
||||
|
||||
pub struct EscalationClient {
|
||||
inbox: RegularFileMailbox,
|
||||
outbox: RegularFileMailbox,
|
||||
session: VerifiedSession,
|
||||
sandbox_key: SigningKey,
|
||||
}
|
||||
|
||||
impl EscalationClient {
|
||||
/// Constructs a static client over already-mounted mailbox directories and live authority.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
inbox: RegularFileMailbox,
|
||||
outbox: RegularFileMailbox,
|
||||
session: VerifiedSession,
|
||||
sandbox_key: SigningKey,
|
||||
) -> Self {
|
||||
Self {
|
||||
inbox,
|
||||
outbox,
|
||||
session,
|
||||
sandbox_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs and atomically publishes one structured argv request.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses invalid argv, random-ID failure, tuple mismatch, or publication failure.
|
||||
pub fn request(
|
||||
&self,
|
||||
command_argv: Vec<Vec<u8>>,
|
||||
justification: String,
|
||||
) -> Result<ClientRequest<'_>, ClientError> {
|
||||
let request_id = random_request_id()?;
|
||||
let request = MailboxRequest::new(
|
||||
self.session.session_binding().clone(),
|
||||
request_id,
|
||||
self.session.session_capability().clone(),
|
||||
self.session.sandbox_credential_id(),
|
||||
command_argv,
|
||||
justification,
|
||||
)?;
|
||||
let signed = encode_signed(&request, &self.sandbox_key)?;
|
||||
let (_, digest) = self.session.verify_request(&signed)?;
|
||||
self.inbox
|
||||
.publish(&request_file_name(request_id), &signed)?;
|
||||
Ok(ClientRequest {
|
||||
client: self,
|
||||
request_id,
|
||||
digest,
|
||||
collector: ResultCollector::default(),
|
||||
terminal: false,
|
||||
cancellation_published: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClientRequest<'a> {
|
||||
client: &'a EscalationClient,
|
||||
request_id: RequestId,
|
||||
digest: RequestDigest,
|
||||
collector: ResultCollector,
|
||||
terminal: bool,
|
||||
cancellation_published: bool,
|
||||
}
|
||||
|
||||
impl ClientRequest<'_> {
|
||||
#[must_use]
|
||||
pub const fn request_id(&self) -> RequestId {
|
||||
self.request_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_digest(&self) -> RequestDigest {
|
||||
self.digest
|
||||
}
|
||||
|
||||
/// Publishes one signed cancellation for this exact request.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a terminal request, duplicate local cancellation, or publication failure.
|
||||
pub fn cancel(&mut self) -> Result<(), ClientError> {
|
||||
if self.terminal || self.cancellation_published {
|
||||
return Err(ClientError::TerminalAlreadyReceived);
|
||||
}
|
||||
let cancellation = MailboxCancellation {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: self.client.session.session_binding().clone(),
|
||||
request_id: self.request_id,
|
||||
session_capability: self.client.session.session_capability().clone(),
|
||||
sandbox_credential_id: self.client.session.sandbox_credential_id(),
|
||||
};
|
||||
let signed = encode_signed(&cancellation, &self.client.sandbox_key)?;
|
||||
self.client
|
||||
.inbox
|
||||
.publish(&cancellation_file_name(self.request_id), &signed)?;
|
||||
self.cancellation_published = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads at most one verified output chunk or terminal record without unbounded buffering.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses malformed, oversized, stale, reordered, duplicated, or mismatched result records.
|
||||
pub fn poll(&mut self) -> Result<ClientEvent, ClientError> {
|
||||
if self.terminal {
|
||||
return Err(ClientError::TerminalAlreadyReceived);
|
||||
}
|
||||
let sequence = self
|
||||
.collector
|
||||
.highest_contiguous_sequence()
|
||||
.map_or(0, |value| value.saturating_add(1));
|
||||
let chunk_name = result_chunk_file_name(self.request_id, sequence);
|
||||
if self.client.outbox.names()?.contains(&chunk_name) {
|
||||
let (encoded, _) = self
|
||||
.client
|
||||
.outbox
|
||||
.read(&chunk_name, MAX_SIGNED_RECORD_BYTES)?;
|
||||
let chunk =
|
||||
self.client
|
||||
.session
|
||||
.verify_result_chunk(&encoded, self.request_id, self.digest)?;
|
||||
let mut collector = self.collector.clone();
|
||||
collector.push(&chunk)?;
|
||||
let acknowledgment = MailboxResultAck {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: self.client.session.session_binding().clone(),
|
||||
request_id: self.request_id,
|
||||
request_sha256: self.digest,
|
||||
session_capability: self.client.session.session_capability().clone(),
|
||||
sandbox_credential_id: self.client.session.sandbox_credential_id(),
|
||||
highest_contiguous_sequence: chunk.sequence,
|
||||
};
|
||||
let signed = encode_signed(&acknowledgment, &self.client.sandbox_key)?;
|
||||
self.client.inbox.publish(
|
||||
&acknowledgment_file_name(self.request_id, chunk.sequence),
|
||||
&signed,
|
||||
)?;
|
||||
self.collector = collector;
|
||||
return Ok(ClientEvent::Output {
|
||||
sequence: chunk.sequence,
|
||||
stream: chunk.stream,
|
||||
bytes: chunk.bytes,
|
||||
});
|
||||
}
|
||||
let result_name = result_file_name(self.request_id);
|
||||
if self.client.outbox.names()?.contains(&result_name) {
|
||||
let encoded = self
|
||||
.client
|
||||
.outbox
|
||||
.consume(&result_name, MAX_SIGNED_RECORD_BYTES)?;
|
||||
let result =
|
||||
self.client
|
||||
.session
|
||||
.verify_result(&encoded, self.request_id, self.digest)?;
|
||||
self.collector.finish(&result)?;
|
||||
self.terminal = true;
|
||||
return Ok(ClientEvent::Terminal(Box::new(result)));
|
||||
}
|
||||
Ok(ClientEvent::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
fn random_request_id() -> Result<RequestId, ClientError> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes)?;
|
||||
Ok(RequestId::new(bytes))
|
||||
}
|
||||
|
|
@ -1,3 +1,232 @@
|
|||
fn main() {
|
||||
let _ = lazaret_core::PROTOCOL_NAME;
|
||||
use std::ffi::OsString;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::codec::decode;
|
||||
use lazaret_core::protocol::VerifiedSession;
|
||||
use lazaret_core::records::{
|
||||
EscalationOutcome, ProtocolPublicationIdentity, SandboxCredentialBundle, SessionBinding,
|
||||
SessionRegistrationResponse,
|
||||
};
|
||||
use lazaret_escalation_client::{ClientEvent, EscalationClient};
|
||||
use lazaret_linux::filesystem::RegularFileMailbox;
|
||||
|
||||
struct Arguments {
|
||||
inbox: PathBuf,
|
||||
outbox: PathBuf,
|
||||
publication: PathBuf,
|
||||
sandbox_credentials: PathBuf,
|
||||
registration: PathBuf,
|
||||
session_binding: PathBuf,
|
||||
justification: String,
|
||||
command_argv: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
eprintln!("lazaret escalation refused: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<u8, Box<dyn std::error::Error>> {
|
||||
let arguments = parse_arguments(std::env::args_os().skip(1).collect())?;
|
||||
let publication: ProtocolPublicationIdentity = read_record(&arguments.publication)?;
|
||||
let bundle: SandboxCredentialBundle = read_record(&arguments.sandbox_credentials)?;
|
||||
let registration: SessionRegistrationResponse = read_record(&arguments.registration)?;
|
||||
let binding: SessionBinding = read_record(&arguments.session_binding)?;
|
||||
let sandbox_key_bytes: [u8; 32] = bundle.sandbox_private_key.as_slice().try_into()?;
|
||||
let sandbox_key = SigningKey::from_bytes(&sandbox_key_bytes);
|
||||
let session = VerifiedSession::new(
|
||||
publication.trust_manifest_sha256,
|
||||
bundle.trust_manifest.tls_ca_sha256,
|
||||
&bundle.trust_manifest,
|
||||
binding,
|
||||
bundle.coordinator_protocol_credential(),
|
||||
bundle.sandbox_protocol_credential(),
|
||||
registration.session_capability,
|
||||
)?;
|
||||
let client = EscalationClient::new(
|
||||
RegularFileMailbox::open(&arguments.inbox)?,
|
||||
RegularFileMailbox::open(&arguments.outbox)?,
|
||||
session,
|
||||
sandbox_key,
|
||||
);
|
||||
let mut request = client.request(arguments.command_argv, arguments.justification)?;
|
||||
loop {
|
||||
match request.poll()? {
|
||||
ClientEvent::Output { stream, bytes, .. } => match stream {
|
||||
lazaret_core::records::ResultStream::Stdout => {
|
||||
io::stdout().write_all(&bytes)?;
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
lazaret_core::records::ResultStream::Stderr => {
|
||||
io::stderr().write_all(&bytes)?;
|
||||
io::stderr().flush()?;
|
||||
}
|
||||
},
|
||||
ClientEvent::Terminal(result) => {
|
||||
return Ok(match result.outcome {
|
||||
EscalationOutcome::Completed => result
|
||||
.exit_code
|
||||
.and_then(|code| u8::try_from(code).ok())
|
||||
.unwrap_or(1),
|
||||
EscalationOutcome::Denied => 3,
|
||||
EscalationOutcome::TimedOut => 4,
|
||||
EscalationOutcome::Cancelled => 5,
|
||||
EscalationOutcome::Failed => 6,
|
||||
});
|
||||
}
|
||||
ClientEvent::Pending => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_record<T>(path: &std::path::Path) -> Result<T, Box<dyn std::error::Error>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned + serde::Serialize,
|
||||
{
|
||||
let mut file = std::fs::File::from(rustix::fs::openat(
|
||||
rustix::fs::CWD,
|
||||
path,
|
||||
rustix::fs::OFlags::RDONLY
|
||||
| rustix::fs::OFlags::NONBLOCK
|
||||
| rustix::fs::OFlags::NOFOLLOW
|
||||
| rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)?);
|
||||
let metadata = file.metadata()?;
|
||||
let length = usize::try_from(metadata.len())?;
|
||||
if !metadata.file_type().is_file() || length > lazaret_core::codec::MAX_SCHEMA_RECORD_BYTES {
|
||||
return Err(io::Error::other("client authority record type or size is invalid").into());
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(length.saturating_add(1));
|
||||
Read::by_ref(&mut file)
|
||||
.take((lazaret_core::codec::MAX_SCHEMA_RECORD_BYTES as u64) + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.len() != length || file.metadata()?.len() != metadata.len() {
|
||||
return Err(io::Error::other("client authority record changed while read").into());
|
||||
}
|
||||
Ok(decode(&bytes)?)
|
||||
}
|
||||
|
||||
fn parse_arguments(mut arguments: Vec<OsString>) -> Result<Arguments, io::Error> {
|
||||
let separator = arguments
|
||||
.iter()
|
||||
.position(|argument| argument == "--")
|
||||
.ok_or_else(|| io::Error::other("command separator is missing"))?;
|
||||
let command = arguments.split_off(separator + 1);
|
||||
arguments.pop();
|
||||
if command.is_empty() {
|
||||
return Err(io::Error::other("command argv is empty"));
|
||||
}
|
||||
let mut inbox = None;
|
||||
let mut outbox = None;
|
||||
let mut publication = None;
|
||||
let mut sandbox_credentials = None;
|
||||
let mut registration = None;
|
||||
let mut session_binding = None;
|
||||
let mut justification = None;
|
||||
let mut options = arguments.into_iter();
|
||||
while let Some(option) = options.next() {
|
||||
let value = options
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::other("client option has no value"))?;
|
||||
match option.to_str() {
|
||||
Some("--inbox") => inbox = Some(PathBuf::from(value)),
|
||||
Some("--outbox") => outbox = Some(PathBuf::from(value)),
|
||||
Some("--publication") => publication = Some(PathBuf::from(value)),
|
||||
Some("--sandbox-credentials") => sandbox_credentials = Some(PathBuf::from(value)),
|
||||
Some("--registration") => registration = Some(PathBuf::from(value)),
|
||||
Some("--session-binding") => session_binding = Some(PathBuf::from(value)),
|
||||
Some("--justification") => {
|
||||
justification = Some(
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| io::Error::other("justification is not UTF-8"))?,
|
||||
);
|
||||
}
|
||||
_ => return Err(io::Error::other("unknown client option")),
|
||||
}
|
||||
}
|
||||
Ok(Arguments {
|
||||
inbox: inbox.ok_or_else(|| io::Error::other("inbox path is missing"))?,
|
||||
outbox: outbox.ok_or_else(|| io::Error::other("outbox path is missing"))?,
|
||||
publication: publication.ok_or_else(|| io::Error::other("publication path is missing"))?,
|
||||
sandbox_credentials: sandbox_credentials
|
||||
.ok_or_else(|| io::Error::other("sandbox credentials path is missing"))?,
|
||||
registration: registration
|
||||
.ok_or_else(|| io::Error::other("registration path is missing"))?,
|
||||
session_binding: session_binding
|
||||
.ok_or_else(|| io::Error::other("session binding path is missing"))?,
|
||||
justification: justification.ok_or_else(|| io::Error::other("justification is missing"))?,
|
||||
command_argv: command.into_iter().map(OsString::into_vec).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_arguments;
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn valid_arguments() -> Vec<OsString> {
|
||||
[
|
||||
"--inbox",
|
||||
"/inbox",
|
||||
"--outbox",
|
||||
"/outbox",
|
||||
"--publication",
|
||||
"/publication",
|
||||
"--sandbox-credentials",
|
||||
"/credentials",
|
||||
"--registration",
|
||||
"/registration",
|
||||
"--session-binding",
|
||||
"/binding",
|
||||
"--justification",
|
||||
"needed",
|
||||
"--",
|
||||
"/usr/bin/tool",
|
||||
"$HOME",
|
||||
"a;b",
|
||||
]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_preserves_exact_argv_without_shell_interpretation() {
|
||||
let parsed = parse_arguments(valid_arguments()).unwrap();
|
||||
assert_eq!(
|
||||
parsed.command_argv,
|
||||
[
|
||||
b"/usr/bin/tool".to_vec(),
|
||||
b"$HOME".to_vec(),
|
||||
b"a;b".to_vec()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_refuses_missing_separator_unknown_options_and_empty_argv() {
|
||||
let mut missing_separator = valid_arguments();
|
||||
missing_separator.retain(|argument| argument != "--");
|
||||
assert!(parse_arguments(missing_separator).is_err());
|
||||
|
||||
let mut unknown = valid_arguments();
|
||||
unknown[0] = OsString::from("--unknown");
|
||||
assert!(parse_arguments(unknown).is_err());
|
||||
|
||||
let mut empty = valid_arguments();
|
||||
empty.truncate(empty.iter().position(|argument| argument == "--").unwrap() + 1);
|
||||
assert!(parse_arguments(empty).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue