Skip to content

Commit 7cf3c61

Browse files
committed
fix(run): mount Sidecar CA material through the sandbox mask
1 parent 9d761b2 commit 7cf3c61

10 files changed

Lines changed: 527 additions & 19 deletions

File tree

crates/firma-run/src/backend/linux_bwrap/mount.rs

Lines changed: 223 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::backend::{
1717
use crate::config::MountSpec;
1818
use crate::error::RunError;
1919
use firma_config_loader::{CONFIG_DIR_NAME, CONFIG_FILE_NAME};
20+
use firma_runtime_state::RunEntryLayout;
2021

2122
const BWRAP_ROOTFS_MODE_ENV: &str = "FIRMA_RUN_BWRAP_ROOTFS_MODE";
2223
const BWRAP_RUNTIME_HOME_ENV: &str = "FIRMA_RUN_BWRAP_RUNTIME_HOME";
@@ -215,14 +216,22 @@ impl BwrapMountPlan {
215216
handle.runtime_dir.display()
216217
),
217218
})?;
219+
// Derived from the runtime layout rather than the launch environment:
220+
// an operator-supplied `SSL_CERT_FILE` could otherwise aim the CA bind
221+
// at a signing key elsewhere in the control-plane runtime.
222+
let run_entry = runtime_layout.run_entry_layout(&handle.identity.sandbox_id);
223+
let runtime_paths = PlanRuntimePaths {
224+
control_plane: &control_plane_runtime,
225+
sandbox: &sandbox_runtime,
226+
run_entry: &run_entry,
227+
};
218228
let mounts = validate_mounts(handle, &control_plane_runtime, &sandbox_runtime)?;
219229
let mut plan = Self::empty();
220230
append_filesystem_layout(
221231
&mut plan,
222232
handle,
223233
&mounts,
224-
&control_plane_runtime,
225-
&sandbox_runtime,
234+
&runtime_paths,
226235
launch,
227236
hardening,
228237
)?;
@@ -373,8 +382,7 @@ fn append_filesystem_layout(
373382
plan: &mut BwrapMountPlan,
374383
handle: &SandboxHandle,
375384
mounts: &[ValidatedMount],
376-
control_plane_runtime: &Path,
377-
sandbox_runtime: &Path,
385+
runtime_paths: &PlanRuntimePaths<'_>,
378386
launch: &LaunchSpec,
379387
hardening: &BwrapHardening,
380388
) -> Result<(), RunError> {
@@ -417,17 +425,36 @@ fn append_filesystem_layout(
417425
.collect::<Vec<_>>();
418426
project_mount_aliases(&mut plan.config_seals, &overlay_specs, masked);
419427

420-
mask_control_plane_runtime(plan, mounts, control_plane_runtime, sandbox_runtime, launch)?;
428+
mask_control_plane_runtime(
429+
plan,
430+
mounts,
431+
runtime_paths.control_plane,
432+
runtime_paths.sandbox,
433+
launch,
434+
)?;
435+
mount_ca_material(plan, runtime_paths.run_entry);
421436
Ok(())
422437
}
423438

439+
/// Host paths that anchor one sandbox filesystem plan.
440+
struct PlanRuntimePaths<'a> {
441+
/// Canonical control-plane runtime root (`FIRMA_STATE_DIR`).
442+
control_plane: &'a Path,
443+
/// Canonical private runtime for the sandbox being planned.
444+
sandbox: &'a Path,
445+
/// Layout of this run's entry inside the control-plane runtime.
446+
run_entry: &'a RunEntryLayout,
447+
}
448+
424449
/// Hide host-side Firma runtime state from the wrapped process tree.
425450
///
426451
/// The read-only host-root bind prevents mutation but not disclosure. The
427452
/// runtime root contains per-run Sidecar and Authority sockets, configuration,
428453
/// metadata, signing keys, and capability seeds, none of which the wrapped
429454
/// process needs. The sandbox-local bwrap runtime remains available separately
430-
/// because the proxy bridge and egress guard require its sockets.
455+
/// because the proxy bridge and egress guard require its sockets, and
456+
/// [`mount_ca_material`] mounts the Sidecar's public CA material back over
457+
/// this mask.
431458
fn mask_control_plane_runtime(
432459
plan: &mut BwrapMountPlan,
433460
mounts: &[ValidatedMount],
@@ -473,6 +500,37 @@ fn mask_control_plane_runtime(
473500
Ok(())
474501
}
475502

503+
/// Mount the Sidecar's public CA material through the control-plane mask.
504+
///
505+
/// [`mask_control_plane_runtime`] tmpfs-masks the whole control-plane runtime,
506+
/// but the launch environment points `SSL_CERT_FILE`, `CURL_CA_BUNDLE`,
507+
/// `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`, and `GIT_SSL_CAINFO` at CA
508+
/// files inside it. Without this restoration the wrapped process opens an
509+
/// unreadable trust store, silently falls back to the system roots, and every
510+
/// MITM-intercepted handshake fails with `certificate signed by unknown
511+
/// authority`.
512+
///
513+
/// Each file is bound individually and read-only. Binding
514+
/// [`RunEntryLayout::ca_dir`] as a directory would also expose
515+
/// [`RunEntryLayout::ca_key`] and let the wrapped process mint certificates
516+
/// trusted by anything configured to trust the Sidecar CA.
517+
///
518+
/// Missing files are skipped: with HTTPS MITM disabled no CA is generated, and
519+
/// the bundle exists only under `ca_trust_mode = "append_system_roots"`.
520+
fn mount_ca_material(plan: &mut BwrapMountPlan, run_entry: &RunEntryLayout) {
521+
for source in [run_entry.ca_cert(), run_entry.ca_bundle()] {
522+
if !source.is_file() {
523+
continue;
524+
}
525+
plan.sandbox_runtime.bind(
526+
BwrapPlanRole::SandboxInfrastructure,
527+
source.clone(),
528+
source,
529+
BwrapBindMode::ReadOnly,
530+
);
531+
}
532+
}
533+
476534
/// Resolves every prepared mount to the exact host source that will be emitted
477535
/// and enforces the source, target, and placement constraints associated with
478536
/// its authority.
@@ -1380,6 +1438,165 @@ mod tests {
13801438
);
13811439
}
13821440

1441+
/// Sandbox handle with no mounts, used by the CA restoration tests.
1442+
#[cfg(target_os = "linux")]
1443+
fn handle_with(
1444+
runtime_dir: std::path::PathBuf,
1445+
identity: crate::identity::RunIdentity,
1446+
) -> crate::backend::SandboxHandle {
1447+
crate::backend::SandboxHandle {
1448+
backend: crate::backend::BackendKind::Bwrap,
1449+
runtime_dir,
1450+
identity,
1451+
mounts: vec![],
1452+
network_policy: crate::config::NetworkPolicy {
1453+
enforce_network_namespace: false,
1454+
fail_closed: true,
1455+
},
1456+
}
1457+
}
1458+
1459+
#[test]
1460+
#[cfg(target_os = "linux")]
1461+
fn filesystem_layout_restores_public_ca_material_over_control_plane_mask() {
1462+
// The control-plane mask hides the whole runtime root, but the launch
1463+
// environment aims `SSL_CERT_FILE` and friends at the run entry's CA.
1464+
// Each public file must be restored individually and after the mask;
1465+
// the signing key must stay hidden, so the CA directory is never bound
1466+
// as a whole.
1467+
let temp = tempfile::tempdir().expect("tempdir");
1468+
let cwd = temp.path().join("workspace");
1469+
std::fs::create_dir_all(&cwd).expect("mkdir workspace");
1470+
let runtime_dir = temp.path().join("runtime");
1471+
std::fs::create_dir_all(&runtime_dir).expect("mkdir runtime");
1472+
1473+
let identity =
1474+
crate::identity::RunIdentity::new(crate::identity::test_agent_id(), "generic");
1475+
let control_plane = temp.path().join("control-plane");
1476+
let runtime_layout = firma_runtime_state::RuntimeLayout::from_root(control_plane.clone());
1477+
let run_entry = runtime_layout.run_entry_layout(&identity.sandbox_id);
1478+
std::fs::create_dir_all(run_entry.ca_dir()).expect("mkdir CA dir");
1479+
for path in [
1480+
run_entry.ca_cert(),
1481+
run_entry.ca_bundle(),
1482+
run_entry.ca_key(),
1483+
] {
1484+
std::fs::write(&path, "").expect("write CA material");
1485+
}
1486+
1487+
let handle = handle_with(runtime_dir, identity);
1488+
let launch = launch_with_cwd_and_config(cwd, None);
1489+
let hardening = super::BwrapHardening::from_env(&launch.env);
1490+
1491+
let plan = super::BwrapMountPlan::build(&runtime_layout, &handle, &launch, &hardening)
1492+
.expect("build mount plan");
1493+
1494+
let rendered = rendered_plan(plan);
1495+
let mask = rendered
1496+
.iter()
1497+
.position(|arg| arg == &canonical(&control_plane))
1498+
.expect("control-plane runtime masked");
1499+
for public in [run_entry.ca_cert(), run_entry.ca_bundle()] {
1500+
let path = public.display().to_string();
1501+
let bind = rendered
1502+
.windows(3)
1503+
.position(|win| win[0] == "--ro-bind" && win[1] == path && win[2] == path)
1504+
.unwrap_or_else(|| panic!("{path} bound read-only through the mask"));
1505+
assert!(
1506+
mask < bind,
1507+
"{path} must be restored after the control-plane mask"
1508+
);
1509+
}
1510+
let key = run_entry.ca_key().display().to_string();
1511+
assert!(
1512+
!rendered.iter().any(|arg| arg == &key),
1513+
"the CA signing key must never be exposed to the sandbox"
1514+
);
1515+
let ca_dir = run_entry.ca_dir().display().to_string();
1516+
assert!(
1517+
!rendered.iter().any(|arg| arg == &ca_dir),
1518+
"binding the CA directory would also expose the signing key"
1519+
);
1520+
}
1521+
1522+
#[test]
1523+
#[cfg(target_os = "linux")]
1524+
fn filesystem_layout_skips_absent_ca_material() {
1525+
// With HTTPS MITM disabled no CA is generated, and the bundle exists
1526+
// only under `ca_trust_mode = "append_system_roots"`. Binding a missing
1527+
// source would make bwrap abort the launch.
1528+
let temp = tempfile::tempdir().expect("tempdir");
1529+
let cwd = temp.path().join("workspace");
1530+
std::fs::create_dir_all(&cwd).expect("mkdir workspace");
1531+
let runtime_dir = temp.path().join("runtime");
1532+
std::fs::create_dir_all(&runtime_dir).expect("mkdir runtime");
1533+
1534+
let identity =
1535+
crate::identity::RunIdentity::new(crate::identity::test_agent_id(), "generic");
1536+
let runtime_layout =
1537+
firma_runtime_state::RuntimeLayout::from_root(temp.path().join("control-plane"));
1538+
let run_entry = runtime_layout.run_entry_layout(&identity.sandbox_id);
1539+
std::fs::create_dir_all(run_entry.root()).expect("mkdir run entry");
1540+
1541+
let handle = handle_with(runtime_dir, identity);
1542+
let launch = launch_with_cwd_and_config(cwd, None);
1543+
let hardening = super::BwrapHardening::from_env(&launch.env);
1544+
1545+
let plan = super::BwrapMountPlan::build(&runtime_layout, &handle, &launch, &hardening)
1546+
.expect("build mount plan");
1547+
1548+
let rendered = rendered_plan(plan);
1549+
let ca_dir = run_entry.ca_dir().display().to_string();
1550+
assert!(
1551+
!rendered.iter().any(|arg| arg.starts_with(&ca_dir)),
1552+
"no CA path should be bound when the material is absent: {rendered:?}"
1553+
);
1554+
}
1555+
1556+
#[test]
1557+
#[cfg(target_os = "linux")]
1558+
fn filesystem_layout_restores_only_the_current_run_ca() {
1559+
// Run entries are siblings under `<runtime>/run`. A concurrent run's CA
1560+
// must stay behind the mask: the plan is keyed on this sandbox's
1561+
// identity, not on whatever CA files happen to exist.
1562+
let temp = tempfile::tempdir().expect("tempdir");
1563+
let cwd = temp.path().join("workspace");
1564+
std::fs::create_dir_all(&cwd).expect("mkdir workspace");
1565+
let runtime_dir = temp.path().join("runtime");
1566+
std::fs::create_dir_all(&runtime_dir).expect("mkdir runtime");
1567+
1568+
let identity =
1569+
crate::identity::RunIdentity::new(crate::identity::test_agent_id(), "generic");
1570+
let other = crate::identity::RunIdentity::new(crate::identity::test_agent_id(), "generic");
1571+
let runtime_layout =
1572+
firma_runtime_state::RuntimeLayout::from_root(temp.path().join("control-plane"));
1573+
let run_entry = runtime_layout.run_entry_layout(&identity.sandbox_id);
1574+
let other_entry = runtime_layout.run_entry_layout(&other.sandbox_id);
1575+
for entry in [&run_entry, &other_entry] {
1576+
std::fs::create_dir_all(entry.ca_dir()).expect("mkdir CA dir");
1577+
std::fs::write(entry.ca_cert(), "").expect("write CA cert");
1578+
}
1579+
1580+
let handle = handle_with(runtime_dir, identity);
1581+
let launch = launch_with_cwd_and_config(cwd, None);
1582+
let hardening = super::BwrapHardening::from_env(&launch.env);
1583+
1584+
let plan = super::BwrapMountPlan::build(&runtime_layout, &handle, &launch, &hardening)
1585+
.expect("build mount plan");
1586+
1587+
let rendered = rendered_plan(plan);
1588+
let own_cert = run_entry.ca_cert().display().to_string();
1589+
let other_cert = other_entry.ca_cert().display().to_string();
1590+
assert!(
1591+
rendered.iter().any(|arg| arg == &own_cert),
1592+
"this run's CA certificate must be restored"
1593+
);
1594+
assert!(
1595+
!rendered.iter().any(|arg| arg == &other_cert),
1596+
"another run's CA certificate must stay behind the mask"
1597+
);
1598+
}
1599+
13831600
#[test]
13841601
#[cfg(target_os = "linux")]
13851602
fn mask_firma_dir_masks_home_firma_outside_cwd_ancestry() {

crates/firma-run/src/routing.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use firma_process_orchestrator::{
1313
ComponentEndpoint, ComponentSpec, LifecycleTimeouts, StackTopology, UnixEndpoint,
1414
spawn_stack_from_plan,
1515
};
16-
use firma_runtime_state::RuntimeLayout;
16+
use firma_runtime_state::{RunEntryLayout, RuntimeLayout};
1717
use firma_secret_provider::spec::http::HttpIntegrationSpec;
1818

1919
#[cfg(unix)]
@@ -830,8 +830,9 @@ fn sidecar_trust_env_overrides(owned_sidecar_marker: Option<&Path>) -> BTreeMap<
830830
let Some(marker) = owned_sidecar_marker else {
831831
return env;
832832
};
833-
let ca_dir = marker.join("firma-ca");
834-
let ca_cert = ca_dir.join("firma-ca.crt");
833+
let run_entry = RunEntryLayout::from_root(marker);
834+
let ca_dir = run_entry.ca_dir();
835+
let ca_cert = run_entry.ca_cert();
835836
env.insert(
836837
"FIRMA_SIDECAR_CA_DIR".to_string(),
837838
ca_dir.display().to_string(),

crates/firma-run/src/runtime/mod.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::path::PathBuf;
44
use std::time::Duration;
55

66
use firma_runtime_state::RuntimeLayout;
7+
use firma_runtime_state::runtime_paths::{CA_BUNDLE_FILE_NAME, CA_CERT_FILE_NAME, CA_DIR_NAME};
78
use firma_secret_provider::IntegrationSpec;
89
use serde::Serialize;
910

@@ -718,7 +719,7 @@ fn build_appended_ca_bundle_with_roots(firma_ca_path: &Path, roots: &[PathBuf])
718719
bundle.push(b'\n');
719720
}
720721
bundle.extend_from_slice(&firma_ca);
721-
let bundle_path = firma_ca_path.with_file_name("firma-ca-bundle.crt");
722+
let bundle_path = firma_ca_path.with_file_name(CA_BUNDLE_FILE_NAME);
722723
if let Err(error) = std::fs::write(&bundle_path, &bundle) {
723724
tracing::warn!(%error, path = %bundle_path.display(), "failed to write combined CA bundle; using sole firma-ca");
724725
return None;
@@ -752,7 +753,7 @@ fn resolve_sidecar_ca_cert_path(network_overrides: &BTreeMap<String, String>) ->
752753
if let Some(ca_dir) = network_overrides.get("FIRMA_SIDECAR_CA_DIR")
753754
&& !ca_dir.trim().is_empty()
754755
{
755-
let path = PathBuf::from(ca_dir).join("firma-ca.crt");
756+
let path = PathBuf::from(ca_dir).join(CA_CERT_FILE_NAME);
756757
if path.is_file() {
757758
return Some(path);
758759
}
@@ -770,19 +771,19 @@ fn resolve_sidecar_ca_cert_path(network_overrides: &BTreeMap<String, String>) ->
770771
if let Ok(ca_dir) = std::env::var("FIRMA_SIDECAR_CA_DIR")
771772
&& !ca_dir.trim().is_empty()
772773
{
773-
let path = PathBuf::from(ca_dir).join("firma-ca.crt");
774+
let path = PathBuf::from(ca_dir).join(CA_CERT_FILE_NAME);
774775
if path.is_file() {
775776
return Some(path);
776777
}
777778
}
778779

779780
let cwd_candidate = std::env::current_dir()
780781
.ok()
781-
.map(|cwd| cwd.join("firma-ca").join("firma-ca.crt"));
782+
.map(|cwd| cwd.join(CA_DIR_NAME).join(CA_CERT_FILE_NAME));
782783
let default_candidates = [
783784
cwd_candidate,
784-
Some(PathBuf::from("/etc/firma/ca/firma-ca.crt")),
785-
Some(PathBuf::from("/var/lib/firma/ca/firma-ca.crt")),
785+
Some(PathBuf::from("/etc/firma/ca").join(CA_CERT_FILE_NAME)),
786+
Some(PathBuf::from("/var/lib/firma/ca").join(CA_CERT_FILE_NAME)),
786787
];
787788

788789
default_candidates

crates/firma-run/src/sidecar/config.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use sha2::{Digest, Sha256};
2121
use crate::error::RunError;
2222
use firma_config_loader::AgentProfile;
2323
use firma_identifiers::AgentId;
24+
use firma_runtime_state::RunEntryLayout;
2425
use firma_sidecar::authority_credentials::SidecarCredentialsConfig;
2526

2627
const MINIMAL_MAPPING_RULES_TOML: &str = "\
@@ -898,7 +899,7 @@ fn override_ca_dir(value: &mut toml::Value, out_path: &Path) -> Result<(), RunEr
898899
out_path.display()
899900
))
900901
})?;
901-
let ca_dir = marker_dir.join("firma-ca");
902+
let ca_dir = RunEntryLayout::from_root(marker_dir).ca_dir();
902903
let sidecar = sidecar_table_mut(value)?;
903904
let ca_table = sidecar
904905
.entry("ca".to_string())

0 commit comments

Comments
 (0)