Bind sandbox handoff authority and restore verifier gates

This commit is contained in:
ms2k 2026-07-16 15:20:13 +09:00
commit 49d5def1a9

View file

@ -7,15 +7,16 @@ use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path, PathBuf};
use lazaret_core::configuration::{ResolvedDomainConfiguration, SandboxLayoutV1};
use lazaret_core::identity::{ContainerId, NamespaceIdentity};
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration, SandboxLayoutV1};
use lazaret_core::identity::{ConfigurationFingerprint, ContainerId, DomainId, NamespaceIdentity};
use lazaret_core::records::VolumeAccess;
use oci_spec::runtime::{
Capabilities, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder,
LinuxNamespaceBuilder, LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder,
Spec, SpecBuilder, UserBuilder,
};
use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, openat2};
use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, StatxFlags, openat2, statx};
use sha2::{Digest, Sha256};
pub const WORKSPACE_DESTINATION: &str = "/workspace";
pub const CONTROL_ROOT_DESTINATION: &str = "/run/lazaret";
@ -122,11 +123,20 @@ impl RootProcess {
struct FileIdentity {
device: u64,
inode: u64,
mount_id: u64,
}
impl FileIdentity {
fn from_descriptor(descriptor: &OwnedFd) -> Result<Self, SandboxBuildError> {
let stat = rustix::fs::fstat(descriptor).map_err(staging_error)?;
let extended = statx(descriptor, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID)
.map_err(staging_error)?;
if !StatxFlags::from_bits_retain(extended.stx_mask).contains(StatxFlags::MNT_ID) {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"host filesystem does not report mount identity",
));
}
if !FileType::from_raw_mode(stat.st_mode).is_file() {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
@ -136,10 +146,142 @@ impl FileIdentity {
Ok(Self {
device: stat.st_dev,
inode: stat.st_ino,
mount_id: extended.stx_mnt_id,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HandoffPathType {
Directory,
RegularFile,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct DerivedHandoffAuthority {
relative_path: PathBuf,
expected_type: HandoffPathType,
identity: FileIdentity,
expected_bytes: Option<Vec<u8>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RuntimeHandoffSource {
relative_path: PathBuf,
expected_type: HandoffPathType,
build_identity: Option<FileIdentity>,
expected_bytes: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct PathAuthorityIdentity {
device: u64,
inode: u64,
mount_id: u64,
}
impl PathAuthorityIdentity {
fn from_plan(plan: &ConfinedPathPlan) -> Self {
Self {
device: plan.target_device().get(),
inode: plan.target_inode().get(),
mount_id: plan.target_mount_id().get(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ConfigurationAuthorityIdentity {
workspace: PathAuthorityIdentity,
runtime_domain: PathAuthorityIdentity,
volumes: Vec<PathAuthorityIdentity>,
}
impl ConfigurationAuthorityIdentity {
fn capture(configuration: &ResolvedDomainConfiguration) -> Self {
Self {
workspace: PathAuthorityIdentity::from_plan(configuration.workspace()),
runtime_domain: PathAuthorityIdentity::from_plan(configuration.runtime_domain_root()),
volumes: configuration
.volumes()
.iter()
.map(|volume| PathAuthorityIdentity::from_plan(volume.source_plan()))
.collect(),
}
}
}
pub struct SandboxHandoffAuthority<'configuration> {
configuration: &'configuration ResolvedDomainConfiguration,
domain_id: DomainId,
configuration_fingerprint: ConfigurationFingerprint,
configuration_authority: ConfigurationAuthorityIdentity,
command: RunscCommand,
derived: Vec<DerivedHandoffAuthority>,
}
impl SandboxHandoffAuthority<'_> {
/// Revalidates every pathname source immediately before attached runsc is released.
///
/// # Errors
/// Refuses changed workspace, volume, Runtime Domain, bundle, rootfs, control, or terminal
/// authority.
pub fn revalidate(&self) -> Result<(), SandboxBuildError> {
if self.configuration.domain_id() != self.domain_id
|| self.configuration.configuration_fingerprint() != self.configuration_fingerprint
|| ConfigurationAuthorityIdentity::capture(self.configuration)
!= self.configuration_authority
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"sandbox handoff configuration does not match its bundle",
));
}
self.configuration
.workspace()
.open_authority()
.map_err(changed_configuration_authority)?;
for volume in self.configuration.volumes() {
volume
.open_source()
.map_err(changed_configuration_authority)?;
}
let runtime_root = self
.configuration
.runtime_domain_root()
.open_authority()
.map_err(changed_configuration_authority)?;
for authority in &self.derived {
let descriptor = open_runtime_source(
&runtime_root,
&authority.relative_path,
authority.expected_type,
authority.expected_bytes.is_some(),
)?;
if descriptor_identity(&descriptor)? != authority.identity {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"runtime handoff source changed identity",
));
}
if let Some(expected) = &authority.expected_bytes {
require_exact_bytes(descriptor, expected)?;
}
}
Ok(())
}
#[must_use]
pub const fn domain_id(&self) -> DomainId {
self.domain_id
}
#[must_use]
pub fn matches_command(&self, command: &RunscCommand) -> bool {
self.command == *command
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HostTerminal {
term: String,
@ -279,9 +421,22 @@ pub struct SandboxBuildRequest<'a> {
pub struct RunscCommand {
program: OsString,
arguments: Vec<OsString>,
bundle_authorization: [u8; 32],
}
impl RunscCommand {
pub(crate) fn new(
program: impl Into<OsString>,
arguments: impl IntoIterator<Item = OsString>,
bundle_authorization: [u8; 32],
) -> Self {
Self {
program: program.into(),
arguments: arguments.into_iter().collect(),
bundle_authorization,
}
}
#[must_use]
pub fn program(&self) -> &OsStr {
&self.program
@ -298,6 +453,10 @@ pub struct SandboxBundle {
specification: Spec,
specification_json: Vec<u8>,
command: RunscCommand,
domain_id: DomainId,
configuration_fingerprint: ConfigurationFingerprint,
configuration_authority: ConfigurationAuthorityIdentity,
runtime_sources: Vec<RuntimeHandoffSource>,
}
impl SandboxBundle {
@ -315,6 +474,78 @@ impl SandboxBundle {
pub const fn command(&self) -> &RunscCommand {
&self.command
}
/// Captures the exact staged sources that must survive until attached runsc release.
///
/// # Errors
/// Refuses absent, changed, symlinked, or incorrectly typed handoff sources.
pub fn capture_handoff_authority<'configuration>(
&self,
configuration: &'configuration ResolvedDomainConfiguration,
) -> Result<SandboxHandoffAuthority<'configuration>, SandboxBuildError> {
if configuration.domain_id() != self.domain_id
|| configuration.configuration_fingerprint() != self.configuration_fingerprint
|| ConfigurationAuthorityIdentity::capture(configuration)
!= self.configuration_authority
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"sandbox handoff configuration does not match its bundle",
));
}
configuration
.workspace()
.open_authority()
.map_err(changed_configuration_authority)?;
for volume in configuration.volumes() {
volume
.open_source()
.map_err(changed_configuration_authority)?;
}
let runtime_root = configuration
.runtime_domain_root()
.open_authority()
.map_err(changed_configuration_authority)?;
let derived = self
.runtime_sources
.iter()
.map(|source| {
let descriptor = open_runtime_source(
&runtime_root,
&source.relative_path,
source.expected_type,
source.expected_bytes.is_some(),
)?;
let identity = descriptor_identity(&descriptor)?;
if source
.build_identity
.is_some_and(|expected| expected != identity)
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged handoff source changed identity after bundle construction",
));
}
if let Some(expected) = &source.expected_bytes {
require_exact_bytes(descriptor, expected)?;
}
Ok(DerivedHandoffAuthority {
relative_path: source.relative_path.clone(),
expected_type: source.expected_type,
identity,
expected_bytes: source.expected_bytes.clone(),
})
})
.collect::<Result<_, SandboxBuildError>>()?;
Ok(SandboxHandoffAuthority {
configuration,
domain_id: self.domain_id,
configuration_fingerprint: self.configuration_fingerprint,
configuration_authority: self.configuration_authority.clone(),
command: self.command.clone(),
derived,
})
}
}
/// Returns the complete protected destination roots used by bundle construction.
@ -371,9 +602,8 @@ pub fn build_sandbox_bundle(
request.terminal.as_ref(),
)?;
let linux = build_linux()?;
let command = build_runsc_command(&paths);
let root = RootBuilder::default()
.path(paths.rootfs)
.path(paths.rootfs.clone())
.readonly(false)
.build()
.map_err(oci_error)?;
@ -389,10 +619,18 @@ pub fn build_sandbox_bundle(
let specification_json = serde_json::to_vec(&specification).map_err(|error| {
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
})?;
let bundle_authorization = Sha256::digest(&specification_json).into();
let command = build_runsc_command(&paths, bundle_authorization);
let runtime_sources =
handoff_runtime_sources(&paths, request.terminal.as_ref(), &specification_json);
Ok(SandboxBundle {
specification,
specification_json,
command,
domain_id: request.configuration.domain_id(),
configuration_fingerprint: request.configuration.configuration_fingerprint(),
configuration_authority: ConfigurationAuthorityIdentity::capture(request.configuration),
runtime_sources,
})
}
@ -404,6 +642,143 @@ struct RuntimePaths {
control_sources: PathBuf,
}
fn handoff_runtime_sources(
paths: &RuntimePaths,
terminal: Option<&StagedHostTerminal>,
specification_json: &[u8],
) -> Vec<RuntimeHandoffSource> {
let container = Path::new("sandbox-control").join(&paths.container_name);
let mut sources = [
(
Path::new("bundles").join(&paths.container_name),
HandoffPathType::Directory,
),
(
Path::new("bundles")
.join(&paths.container_name)
.join("config.json"),
HandoffPathType::RegularFile,
),
(PathBuf::from("runsc"), HandoffPathType::Directory),
(
PathBuf::from("state-anchor/merged"),
HandoffPathType::Directory,
),
(container.clone(), HandoffPathType::Directory),
(container.join("credentials"), HandoffPathType::Directory),
(
container.join("mailbox/requests"),
HandoffPathType::Directory,
),
(
container.join("mailbox/results"),
HandoffPathType::Directory,
),
(
container.join("bin/lazaret-escalate"),
HandoffPathType::RegularFile,
),
]
.into_iter()
.map(|(relative_path, expected_type)| {
let expected_bytes = (relative_path
== Path::new("bundles")
.join(&paths.container_name)
.join("config.json"))
.then(|| specification_json.to_vec());
RuntimeHandoffSource {
relative_path,
expected_type,
build_identity: None,
expected_bytes,
}
})
.collect::<Vec<_>>();
if let Some(terminal) = terminal {
sources.push(RuntimeHandoffSource {
relative_path: terminal.relative_source.clone(),
expected_type: HandoffPathType::RegularFile,
build_identity: Some(terminal.identity),
expected_bytes: None,
});
}
sources
}
fn open_runtime_source(
runtime_root: &OwnedFd,
relative_path: &Path,
expected_type: HandoffPathType,
readable: bool,
) -> Result<OwnedFd, SandboxBuildError> {
let descriptor = openat2(
runtime_root,
relative_path,
if readable {
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW
} else {
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW
},
Mode::empty(),
ResolveFlags::BENEATH | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
)
.map_err(staging_error)?;
let stat = rustix::fs::fstat(&descriptor).map_err(staging_error)?;
let actual = FileType::from_raw_mode(stat.st_mode);
let matches = match expected_type {
HandoffPathType::Directory => actual.is_dir(),
HandoffPathType::RegularFile => actual.is_file(),
};
if !matches {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"runtime handoff source changed type",
));
}
Ok(descriptor)
}
fn require_exact_bytes(descriptor: OwnedFd, expected: &[u8]) -> Result<(), SandboxBuildError> {
let limit = u64::try_from(expected.len())
.ok()
.and_then(|length| length.checked_add(1))
.ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged OCI configuration length is invalid",
)
})?;
let mut observed = Vec::new();
File::from(descriptor)
.take(limit)
.read_to_end(&mut observed)
.map_err(staging_error)?;
if observed != expected {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"staged OCI configuration differs from its bundle",
));
}
Ok(())
}
fn descriptor_identity(descriptor: &OwnedFd) -> Result<FileIdentity, SandboxBuildError> {
let stat = rustix::fs::fstat(descriptor).map_err(staging_error)?;
let extended =
statx(descriptor, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID).map_err(staging_error)?;
if !StatxFlags::from_bits_retain(extended.stx_mask).contains(StatxFlags::MNT_ID) {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"host filesystem does not report mount identity",
));
}
Ok(FileIdentity {
device: stat.st_dev,
inode: stat.st_ino,
mount_id: extended.stx_mnt_id,
})
}
fn runtime_paths(
configuration: &ResolvedDomainConfiguration,
container_id: ContainerId,
@ -491,10 +866,10 @@ fn build_linux() -> Result<oci_spec::runtime::Linux, SandboxBuildError> {
.map_err(oci_error)
}
fn build_runsc_command(paths: &RuntimePaths) -> RunscCommand {
RunscCommand {
program: OsString::from("runsc"),
arguments: vec![
fn build_runsc_command(paths: &RuntimePaths, bundle_authorization: [u8; 32]) -> RunscCommand {
RunscCommand::new(
"runsc",
[
OsString::from(format!(
"-root={}",
paths
@ -515,7 +890,8 @@ fn build_runsc_command(paths: &RuntimePaths) -> RunscCommand {
)),
OsString::from(&paths.container_name),
],
}
bundle_authorization,
)
}
fn build_process(