Skip to content

Commit 64fc110

Browse files
committed
composefs-ctl: Add require_signature mount option to varlink Mount/OciMount
Out-of-tree container-lifecycle clients (e.g. composefs-run) want to request a mount fd and know the kernel actually verified a fs-verity signature was enrolled on the image, without re-implementing that check themselves against the repository on the side. Right now the varlink `Mount`/`OciMount` methods have no way to express that requirement: a caller can only check signature enrollment out-of-band and race the mount, or trust the caller of the mount to have done so. Give `MountParams` an optional `require_signature` field. When set, `run_mount`/`run_oci_mount` call `has_verity_signature()` on the freshly-opened image fd before mounting, and refuse with a new `SignatureRequired` error (added per-interface, so `RepositoryError` and `OciError` each get their own variant) if the image has fs-verity enabled but no signature was enrolled. This only checks that *some* signature exists, matching the kernel's own `.fs-verity` keyring enforcement at read time; it does not perform certificate-trust validation, which remains the job of the separate Seal/Sign/Verify methods. The field defaults to `None`/unset so existing callers and the wire format are unaffected. Adds a real varlink-wire-protocol integration test (a typed OciProxy client talking to a spawned `cfsctl oci varlink` server over its Unix socket) covering all three cases: a signed, kernel-enrolled image mounts successfully with the flag set, an unsigned image is refused with `SignatureRequired`, and the same unsigned image mounts fine when the flag is left unset.
1 parent fb79e92 commit 64fc110

3 files changed

Lines changed: 298 additions & 4 deletions

File tree

crates/composefs-ctl/src/varlink.rs

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ pub enum RepositoryError {
129129
/// The ref name that was not found.
130130
reference: String,
131131
},
132+
/// `require_signature` was requested but the underlying image has no
133+
/// kernel-enrolled fs-verity signature.
134+
SignatureRequired {
135+
/// The name/selector that was requested to be mounted.
136+
name: String,
137+
},
132138
/// An unexpected internal error occurred while servicing the request.
133139
InternalError {
134140
/// Description of the failure.
@@ -460,6 +466,16 @@ pub struct MountParams {
460466
pub overlay: Option<bool>,
461467
/// Whether to mount read-write (only meaningful with overlay).
462468
pub read_write: Option<bool>,
469+
/// When `true`, require that the underlying EROFS image has a kernel
470+
/// fs-verity signature enrolled (i.e. a signature was successfully
471+
/// registered via `FS_IOC_ENABLE_VERITY` at pull/import time — see
472+
/// `has_verity_signature()`). If the image has verity enabled but no
473+
/// enrolled signature, the mount is refused. This does NOT perform any
474+
/// certificate-trust validation (see the separate Seal/Sign/Verify
475+
/// varlink methods for that) — it only confirms *some* signature was
476+
/// enrolled and is therefore subject to the kernel's own enforcement
477+
/// via `.fs-verity` keyring lookups at read time.
478+
pub require_signature: Option<bool>,
463479
}
464480

465481
impl MountParams {
@@ -504,12 +520,45 @@ pub struct MountReply {
504520
pub fd_index: u32,
505521
}
506522

523+
/// Check whether `name`'s underlying image has a kernel-enrolled fs-verity
524+
/// signature, for [`MountParams::require_signature`].
525+
///
526+
/// Opens the image fd fresh via [`Repository::open_image`] rather than
527+
/// threading through the fd that `mount_with_options` will open again
528+
/// internally: this is a cheap read-only open of a file that's immutable
529+
/// once fs-verity is enabled, and keeps this varlink-layer policy check out
530+
/// of the core `Repository` API.
531+
fn image_has_verity_signature<ObjectID: FsVerityHashValue>(
532+
repo: &Repository<ObjectID>,
533+
name: &str,
534+
) -> anyhow::Result<bool> {
535+
let (fd, _enable_verity) = repo.open_image(name)?;
536+
composefs::fsverity::has_verity_signature(&fd)
537+
.context("Checking kernel fs-verity signature enrollment")
538+
}
539+
507540
fn run_mount<ObjectID: FsVerityHashValue>(
508541
repo: &Repository<ObjectID>,
509542
name: &str,
510543
params: &MountParams,
511544
fds: Vec<std::os::fd::OwnedFd>,
512545
) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), RepositoryError> {
546+
if params.require_signature.unwrap_or(false) {
547+
match image_has_verity_signature(repo, name) {
548+
Ok(true) => {}
549+
Ok(false) => {
550+
return Err(RepositoryError::SignatureRequired {
551+
name: name.to_string(),
552+
});
553+
}
554+
Err(e) => {
555+
return Err(RepositoryError::InternalError {
556+
message: format!("Checking fs-verity signature enrollment: {e:#}"),
557+
});
558+
}
559+
}
560+
}
561+
513562
let options = params.to_mount_options(fds)?;
514563

515564
let mount_fd =
@@ -555,6 +604,22 @@ fn run_oci_mount<ObjectID: composefs::fsverity::FsVerityHashValue>(
555604
},
556605
})?;
557606

607+
if params.require_signature.unwrap_or(false) {
608+
match image_has_verity_signature(repo, &erofs_id.to_hex()) {
609+
Ok(true) => {}
610+
Ok(false) => {
611+
return Err(oci::OciError::SignatureRequired {
612+
image: image.to_string(),
613+
});
614+
}
615+
Err(e) => {
616+
return Err(oci::OciError::InternalError {
617+
message: format!("Checking fs-verity signature enrollment: {e:#}"),
618+
});
619+
}
620+
}
621+
}
622+
558623
let options = params
559624
.to_mount_options(fds)
560625
.map_err(|e| oci::OciError::InternalError {
@@ -1058,7 +1123,9 @@ mod service_impl {
10581123
///
10591124
/// If overlay upper/work directories are needed, pass them as two fds
10601125
/// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
1061-
/// mount that the caller can attach with `move_mount()`.
1126+
/// mount that the caller can attach with `move_mount()`. If
1127+
/// `options.require_signature` is `true`, the mount is refused unless
1128+
/// the underlying image has a kernel-enrolled fs-verity signature.
10621129
#[zlink(return_fds)]
10631130
async fn mount(
10641131
&self,
@@ -1228,7 +1295,9 @@ mod service_impl {
12281295
///
12291296
/// If overlay upper/work directories are needed, pass them as two fds
12301297
/// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
1231-
/// mount that the caller can attach with `move_mount()`.
1298+
/// mount that the caller can attach with `move_mount()`. If
1299+
/// `options.require_signature` is `true`, the mount is refused unless
1300+
/// the underlying image has a kernel-enrolled fs-verity signature.
12321301
#[zlink(return_fds)]
12331302
async fn mount(
12341303
&self,
@@ -1457,7 +1526,9 @@ mod service_impl {
14571526
/// Resolves the image by ref name or `sha256:` digest, finds its
14581527
/// EROFS image (or boot variant if `bootable` is true), and creates
14591528
/// a composefs mount. If `options.overlay` is true, the fd array
1460-
/// must contain upperdir and workdir fds.
1529+
/// must contain upperdir and workdir fds. If `options.require_signature`
1530+
/// is `true`, the mount is refused unless the resolved EROFS image has
1531+
/// a kernel-enrolled fs-verity signature.
14611532
#[zlink(interface = "org.composefs.Oci", return_fds)]
14621533
async fn oci_mount(
14631534
&self,
@@ -2180,6 +2251,12 @@ pub mod oci {
21802251
/// Description of the failure.
21812252
message: String,
21822253
},
2254+
/// `require_signature` was requested but the underlying image has no
2255+
/// kernel-enrolled fs-verity signature.
2256+
SignatureRequired {
2257+
/// The image reference that lacks an enrolled signature.
2258+
image: String,
2259+
},
21832260
/// An unexpected internal error occurred while servicing the request.
21842261
InternalError {
21852262
/// Description of the failure.
@@ -2199,6 +2276,9 @@ pub mod oci {
21992276
OciError::SignatureVerificationFailed { message } => {
22002277
write!(f, "SignatureVerificationFailed: {message}")
22012278
}
2279+
OciError::SignatureRequired { image } => {
2280+
write!(f, "SignatureRequired: {image}")
2281+
}
22022282
OciError::InternalError { message } => write!(f, "InternalError: {message}"),
22032283
}
22042284
}
@@ -2223,6 +2303,8 @@ pub mod proxy {
22232303
RepositoryError,
22242304
};
22252305
#[cfg(feature = "oci")]
2306+
use super::{MountParams, MountReply};
2307+
#[cfg(feature = "oci")]
22262308
use zlink::futures_util::Stream;
22272309

22282310
/// Typed client for the `org.composefs.Repository` interface.
@@ -2345,6 +2427,17 @@ pub mod proxy {
23452427
cert_pem: Option<&str>,
23462428
) -> zlink::Result<Result<VerifyReply, OciError>>;
23472429

2430+
/// Mount an OCI image and return the detached mount fd.
2431+
#[zlink(return_fds)]
2432+
async fn oci_mount(
2433+
&mut self,
2434+
handle: u64,
2435+
image: &str,
2436+
bootable: bool,
2437+
options: MountParams,
2438+
#[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
2439+
) -> zlink::Result<(Result<MountReply, OciError>, Vec<std::os::fd::OwnedFd>)>;
2440+
23482441
/// Pull an OCI image, streaming progress frames.
23492442
#[zlink(more, rename = "Pull")]
23502443
async fn pull(

crates/composefs-integration-tests/src/tests/privileged.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,176 @@ fn privileged_varlink_kernel_sig_no_cert() -> Result<()> {
15291529
}
15301530
integration_test!(privileged_varlink_kernel_sig_no_cert);
15311531

1532+
/// Attaches a detached mount fd (as returned by the `OciMount`/`Mount`
1533+
/// varlink methods) at a fresh temporary directory, and detaches it again
1534+
/// on drop so the test doesn't leak mounts.
1535+
struct AttachedMount {
1536+
dir: tempfile::TempDir,
1537+
}
1538+
1539+
impl AttachedMount {
1540+
fn new(fs_fd: std::os::fd::OwnedFd) -> Result<Self> {
1541+
let dir = tempfile::tempdir()?;
1542+
composefs_oci::composefs::mount::mount_at(fs_fd, rustix::fs::CWD, dir.path())
1543+
.context("Attaching detached mount fd")?;
1544+
Ok(Self { dir })
1545+
}
1546+
1547+
fn path(&self) -> &Path {
1548+
self.dir.path()
1549+
}
1550+
}
1551+
1552+
impl Drop for AttachedMount {
1553+
fn drop(&mut self) {
1554+
let _ = rustix::mount::unmount(self.dir.path(), rustix::mount::UnmountFlags::DETACH);
1555+
}
1556+
}
1557+
1558+
/// Extract the fd at `index` from a `return_fds` reply's fd vector.
1559+
fn take_fd(mut fds: Vec<std::os::fd::OwnedFd>, index: u32) -> Result<std::os::fd::OwnedFd> {
1560+
let index = index as usize;
1561+
ensure!(
1562+
index < fds.len(),
1563+
"fd_index {index} out of range ({} fds returned)",
1564+
fds.len()
1565+
);
1566+
Ok(fds.remove(index))
1567+
}
1568+
1569+
/// `OciMount`'s `require_signature` option: mounting a kernel-signature-
1570+
/// enrolled image with the flag set succeeds and yields a real, working
1571+
/// mount; mounting an unsigned image with the same flag is refused with
1572+
/// `SignatureRequired`; and the flag is opt-in — the unsigned image mounts
1573+
/// fine when it's left unset.
1574+
///
1575+
/// This is the varlink-layer building block for out-of-tree
1576+
/// container-lifecycle clients (e.g. composefs-run) that want "only give me
1577+
/// a mount if the kernel actually verified a fs-verity signature was
1578+
/// enrolled" without re-implementing that check themselves on top of the
1579+
/// existing `Mount`/`OciMount` methods.
1580+
fn privileged_varlink_require_signature() -> Result<()> {
1581+
if require_privileged("privileged_varlink_require_signature")?.is_some() {
1582+
return Ok(());
1583+
}
1584+
if !fsverity_keyring_exists()? {
1585+
println!(
1586+
"SKIP: kernel lacks CONFIG_FS_VERITY_BUILTIN_SIGNATURES support (.fs-verity keyring not found)"
1587+
);
1588+
return Ok(());
1589+
}
1590+
1591+
let sh = Shell::new()?;
1592+
let cfsctl_bin = cfsctl()?;
1593+
let fixture_dir = tempfile::tempdir()?;
1594+
1595+
let (cert_pem, key_pem) = composefs_oci::signing::generate_test_keypair();
1596+
let cert_path = fixture_dir.path().join("cert.pem");
1597+
let key_path = fixture_dir.path().join("key.pem");
1598+
std::fs::write(&cert_path, &cert_pem)?;
1599+
std::fs::write(&key_path, &key_pem)?;
1600+
1601+
// A real signed-and-enrolled image, and a plain image that was never
1602+
// signed at all (so it can never get a kernel signature regardless of
1603+
// keyring state).
1604+
let signed_layout =
1605+
build_signed_oci_layout(&sh, &cfsctl_bin, fixture_dir.path(), &cert_path, &key_path)?;
1606+
let unsigned_layout = create_oci_layout(fixture_dir.path())?;
1607+
1608+
cmd!(sh, "{cfsctl_bin} keyring add-cert {cert_path}").read()?;
1609+
1610+
let verity_dir = VerityTempDir::new()?;
1611+
let repo = verity_dir.path().join("repo");
1612+
cmd!(sh, "{cfsctl_bin} --repo {repo} init").run()?;
1613+
1614+
let svc = VarlinkService::oci(&repo)?;
1615+
1616+
svc.proxy_pull(
1617+
&format!("oci:{}", signed_layout.display()),
1618+
Some("signed-image"),
1619+
false,
1620+
)
1621+
.context("Pull (signed) transport error")?
1622+
.map_err(|e| anyhow::anyhow!("Pull of signed image should succeed: {e:?}"))?;
1623+
ensure!(
1624+
image_has_verity_signature(&svc, &repo, "signed-image")?,
1625+
"expected the kernel to have enrolled the signature on the signed image"
1626+
);
1627+
1628+
svc.proxy_pull(
1629+
&format!("oci:{}", unsigned_layout.display()),
1630+
Some("unsigned-image"),
1631+
false,
1632+
)
1633+
.context("Pull (unsigned) transport error")?
1634+
.map_err(|e| anyhow::anyhow!("Pull of unsigned image should succeed: {e:?}"))?;
1635+
ensure!(
1636+
!image_has_verity_signature(&svc, &repo, "unsigned-image")?,
1637+
"expected no signature to be enrolled on the never-signed image"
1638+
);
1639+
1640+
// Attaches a successful OciMount reply and checks it's a real, working
1641+
// mount of the shared `hello.txt` fixture (see `create_oci_layout`).
1642+
const HELLO_CONTENTS: &str = "hello from test layer\n";
1643+
let assert_mount_works =
1644+
|reply: composefs_ctl::varlink::MountReply, fds: Vec<std::os::fd::OwnedFd>| -> Result<()> {
1645+
let mount = AttachedMount::new(take_fd(fds, reply.fd_index)?)?;
1646+
let hello = std::fs::read_to_string(mount.path().join("hello.txt"))?;
1647+
ensure!(
1648+
hello == HELLO_CONTENTS,
1649+
"unexpected mounted file content: {hello:?}"
1650+
);
1651+
Ok(())
1652+
};
1653+
1654+
// `require_signature: true` on the signed image succeeds and yields a
1655+
// real, working mount.
1656+
{
1657+
let (result, fds) = svc
1658+
.proxy_oci_mount("signed-image", false, Some(true))
1659+
.context("OciMount (signed, required) transport error")?;
1660+
let reply = result.map_err(|e| {
1661+
anyhow::anyhow!(
1662+
"OciMount with require_signature on a signed image should succeed: {e:?}"
1663+
)
1664+
})?;
1665+
assert_mount_works(reply, fds)?;
1666+
}
1667+
1668+
// `require_signature: true` on the unsigned image must be refused with
1669+
// the specific `SignatureRequired` error, not just any error.
1670+
{
1671+
let (result, fds) = svc
1672+
.proxy_oci_mount("unsigned-image", false, Some(true))
1673+
.context("OciMount (unsigned, required) transport error")?;
1674+
ensure!(fds.is_empty(), "expected no fds on a refused mount");
1675+
match result {
1676+
Err(composefs_ctl::varlink::oci::OciError::SignatureRequired { image }) => {
1677+
ensure!(
1678+
image == "unsigned-image",
1679+
"unexpected image in SignatureRequired: {image}"
1680+
);
1681+
}
1682+
other => bail!("expected OciError::SignatureRequired, got: {other:?}"),
1683+
}
1684+
}
1685+
1686+
// `require_signature` is opt-in: leaving it unset still mounts the
1687+
// unsigned image successfully.
1688+
{
1689+
let (result, fds) = svc
1690+
.proxy_oci_mount("unsigned-image", false, None)
1691+
.context("OciMount (unsigned, not required) transport error")?;
1692+
let reply = result.map_err(|e| {
1693+
anyhow::anyhow!("OciMount without require_signature should succeed: {e:?}")
1694+
})?;
1695+
assert_mount_works(reply, fds)?;
1696+
}
1697+
1698+
Ok(())
1699+
}
1700+
integration_test!(privileged_varlink_require_signature);
1701+
15321702
/// `cfsctl keyring add-cert` on its own, independent of any OCI pull flow.
15331703
/// Branches on kernel support rather than skipping, since both outcomes
15341704
/// (success, or a clear `KeyringNotFound` error) are meaningful and worth

0 commit comments

Comments
 (0)