Skip to content

Commit 063ff54

Browse files
committed
oci: Add OCI image signing with detached signature artifacts
Implement PKCS#7 signing and verification for composefs fsverity digests, following the OCI referrers pattern for signature artifact storage. Key features: - cfsctl oci sign: Sign images with X.509 cert/key pairs - cfsctl oci verify: Verify signatures against trusted certificates - cfsctl oci pull --require-signature: Enforce signature verification - cfsctl keyring add-cert: Inject certs into kernel .fs-verity keyring - Signature artifacts stored as OCI referrers with subject field The signature artifact format uses: - artifactType: application/vnd.composefs.signature.v1 - Layers contain PKCS#7 DER signatures - Algorithm annotation: composefs.algorithm: fsverity-sha512-12 Assisted-by: OpenCode (Claude claude-opus-4-5@20251101)
1 parent b470f73 commit 063ff54

16 files changed

Lines changed: 4638 additions & 102 deletions

File tree

crates/cfsctl/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ version.workspace = true
1313
[features]
1414
default = ['pre-6.15', 'oci']
1515
http = ['composefs-http']
16-
oci = ['composefs-oci']
16+
oci = ['composefs-oci', 'oci-spec']
1717
rhel9 = ['composefs/rhel9']
1818
'pre-6.15' = ['composefs/pre-6.15']
1919

@@ -27,6 +27,7 @@ composefs-oci = { workspace = true, optional = true }
2727
composefs-http = { workspace = true, optional = true }
2828
env_logger = { version = "0.11.0", default-features = false }
2929
hex = { version = "0.4.0", default-features = false }
30+
oci-spec = { version = "0.8.0", default-features = false, optional = true }
3031
rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] }
3132
tokio = { version = "1.24.2", default-features = false }
3233

crates/cfsctl/src/main.rs

Lines changed: 531 additions & 30 deletions
Large diffs are not rendered by default.

crates/composefs-oci/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ containers-image-proxy = { version = "0.9.2", default-features = false }
2020
hex = { version = "0.4.0", default-features = false }
2121
indicatif = { version = "0.17.0", default-features = false, features = ["tokio"] }
2222
oci-spec = { version = "0.8.0", default-features = false }
23+
openssl = "0.10"
2324
rustix = { version = "1.0.0", features = ["fs"] }
2425
sha2 = { version = "0.10.1", default-features = false }
2526
tar = { version = "0.4.38", default-features = false }

crates/composefs-oci/src/image.rs

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,66 @@ pub fn process_entry<ObjectID: FsVerityHashValue>(
8585
Ok(())
8686
}
8787

88+
/// Compute per-layer composefs digests for an OCI image.
89+
///
90+
/// For each layer, builds a single-layer filesystem and computes its EROFS fsverity digest.
91+
/// These digests can be stored in a composefs signature artifact.
92+
///
93+
/// Per-layer digests are computed without `transform_for_oci()` since individual layers
94+
/// typically don't have the `/usr` directory needed for the OCI root metadata transform.
95+
///
96+
/// The final merged digest (the digest of the complete flattened filesystem with
97+
/// `transform_for_oci()` applied) can be obtained from `seal()` or `create_filesystem()`.
98+
///
99+
/// **Security note**: When `config_verity` is `None`, layer content is not verified against
100+
/// the config's diff_ids. Callers MUST provide a trusted `config_verity` when computing
101+
/// digests that will be used in signature artifacts. Without verity, a compromised repository
102+
/// could cause digests to be computed over substituted layer content.
103+
#[context("Computing per-layer digests")]
104+
pub fn compute_per_layer_digests<ObjectID: FsVerityHashValue>(
105+
repo: &Repository<ObjectID>,
106+
config_name: &str,
107+
config_verity: Option<&ObjectID>,
108+
) -> Result<Vec<ObjectID>> {
109+
let (config, map) = crate::open_config(repo, config_name, config_verity)?;
110+
111+
let mut layer_digests = Vec::with_capacity(config.rootfs().diff_ids().len());
112+
113+
for diff_id in config.rootfs().diff_ids() {
114+
let layer_verity = map
115+
.get(diff_id.as_str())
116+
.context("OCI config splitstream missing named ref to layer")?;
117+
118+
let mut single_fs = FileSystem::new(Stat::uninitialized());
119+
let mut layer_stream =
120+
repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
121+
while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
122+
process_entry(&mut single_fs, entry)?;
123+
}
124+
layer_digests.push(single_fs.compute_image_id());
125+
}
126+
127+
Ok(layer_digests)
128+
}
129+
130+
/// Computes the composefs merged digest (image ID) for an OCI container.
131+
///
132+
/// This is the fs-verity digest of the merged filesystem created from all layers.
133+
/// This digest is deterministic for a given OCI image and is used in signature
134+
/// artifacts as the "merged" entry.
135+
///
136+
/// If `config_verity` is given, it is used for fast lookup. Otherwise, the config
137+
/// and layers will be hashed to verify their content.
138+
#[context("Computing merged digest")]
139+
pub fn compute_merged_digest<ObjectID: FsVerityHashValue>(
140+
repo: &Repository<ObjectID>,
141+
config_name: &str,
142+
config_verity: Option<&ObjectID>,
143+
) -> Result<ObjectID> {
144+
let fs = create_filesystem(repo, config_name, config_verity)?;
145+
Ok(fs.compute_image_id())
146+
}
147+
88148
/// Creates a filesystem from the given OCI container. No special transformations are performed to
89149
/// make the filesystem bootable.
90150
///
@@ -144,7 +204,12 @@ mod test {
144204
fsverity::Sha256HashValue,
145205
tree::{LeafContent, RegularFile, Stat},
146206
};
147-
use std::{cell::RefCell, collections::BTreeMap, io::BufRead, io::Read, path::PathBuf};
207+
use std::{
208+
cell::RefCell,
209+
collections::BTreeMap,
210+
io::BufRead,
211+
path::{Path, PathBuf},
212+
};
148213

149214
use super::*;
150215

@@ -339,7 +404,7 @@ mod test {
339404
let by_path = |p: &str| -> &TarEntry<Sha256HashValue> {
340405
entries
341406
.iter()
342-
.find(|e| e.path == PathBuf::from(p))
407+
.find(|e| e.path == Path::new(p))
343408
.unwrap_or_else(|| panic!("missing entry for {p}"))
344409
};
345410

@@ -472,7 +537,7 @@ mod test {
472537
// Find the *last* /bin entry, which should be the symlink.
473538
let bin_entries: Vec<_> = entries
474539
.iter()
475-
.filter(|e| e.path == PathBuf::from("/bin"))
540+
.filter(|e| e.path == Path::new("/bin"))
476541
.collect();
477542
assert!(
478543
bin_entries.len() >= 2,
@@ -503,6 +568,94 @@ mod test {
503568
Ok(())
504569
}
505570

571+
/// Helper to import a baseimage layer and create an OCI config for it.
572+
/// Returns (config_digest, config_verity, diff_id).
573+
fn import_baseimage_with_config(
574+
repo: &std::sync::Arc<Repository<Sha256HashValue>>,
575+
) -> (String, Sha256HashValue, String) {
576+
use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder};
577+
578+
let (layer_data, diff_id) = build_baseimage();
579+
let layer_verity =
580+
crate::import_layer(repo, &diff_id, None, &mut layer_data.as_slice()).unwrap();
581+
582+
let rootfs = RootFsBuilder::default()
583+
.typ("layers")
584+
.diff_ids(vec![diff_id.clone()])
585+
.build()
586+
.unwrap();
587+
let config = ImageConfigurationBuilder::default()
588+
.architecture("amd64")
589+
.os("linux")
590+
.rootfs(rootfs)
591+
.build()
592+
.unwrap();
593+
594+
let mut refs = std::collections::HashMap::new();
595+
refs.insert(Box::from(diff_id.as_str()), layer_verity);
596+
597+
let (config_digest, config_verity) = crate::write_config(repo, &config, refs).unwrap();
598+
(config_digest, config_verity, diff_id)
599+
}
600+
601+
#[test]
602+
fn test_compute_per_layer_digests() {
603+
use composefs::{repository::Repository, test::tempdir};
604+
use rustix::fs::CWD;
605+
use std::sync::Arc;
606+
607+
let repo_dir = tempdir();
608+
let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
609+
610+
let (config_digest, config_verity, _diff_id) = import_baseimage_with_config(&repo);
611+
612+
// Compute per-layer digests (with verity)
613+
let digests =
614+
compute_per_layer_digests(&repo, &config_digest, Some(&config_verity)).unwrap();
615+
assert_eq!(digests.len(), 1, "expected exactly 1 per-layer digest");
616+
617+
// Determinism: calling again should produce the same result
618+
let digests2 =
619+
compute_per_layer_digests(&repo, &config_digest, Some(&config_verity)).unwrap();
620+
assert_eq!(
621+
digests, digests2,
622+
"per-layer digests should be deterministic"
623+
);
624+
625+
// Also works without verity (slower path that verifies content hashes)
626+
let digests3 = compute_per_layer_digests(&repo, &config_digest, None).unwrap();
627+
assert_eq!(
628+
digests, digests3,
629+
"verity and non-verity paths should agree"
630+
);
631+
}
632+
633+
#[test]
634+
fn test_per_layer_digest_differs_from_merged() {
635+
use composefs::{repository::Repository, test::tempdir};
636+
use rustix::fs::CWD;
637+
use std::sync::Arc;
638+
639+
let repo_dir = tempdir();
640+
let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
641+
642+
let (config_digest, config_verity, _diff_id) = import_baseimage_with_config(&repo);
643+
644+
let per_layer =
645+
compute_per_layer_digests(&repo, &config_digest, Some(&config_verity)).unwrap();
646+
assert_eq!(per_layer.len(), 1);
647+
648+
let merged_fs = create_filesystem(&repo, &config_digest, Some(&config_verity)).unwrap();
649+
let merged_digest = merged_fs.compute_image_id();
650+
651+
// The merged filesystem applies transform_for_oci() which copies /usr metadata
652+
// to the root, so the digests should differ.
653+
assert_ne!(
654+
per_layer[0], merged_digest,
655+
"per-layer and merged digests should differ because of transform_for_oci"
656+
);
657+
}
658+
506659
#[test]
507660
fn test_process_entry() -> Result<()> {
508661
let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());

crates/composefs-oci/src/lib.rs

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@
88
//! - Pulling container images from registries using skopeo
99
//! - Converting OCI image layers from tar format to composefs split streams
1010
//! - Creating mountable filesystems from OCI image configurations
11-
//! - Sealing containers with fs-verity hashes for integrity verification
11+
//! - Signing and verifying images using fs-verity PKCS#7 signatures
1212
1313
pub mod image;
1414
pub mod oci_image;
15+
pub mod signature;
16+
pub mod signing;
1517
pub mod skopeo;
1618
pub mod tar;
1719

1820
use std::{collections::HashMap, io::Read, sync::Arc};
1921

20-
use anyhow::{bail, ensure, Context, Result};
22+
use anyhow::{ensure, Result};
2123
use containers_image_proxy::ImageProxyConfig;
2224
use oci_spec::image::ImageConfiguration;
2325
use sha2::{Digest, Sha256};
@@ -29,10 +31,11 @@ use crate::tar::get_entry;
2931

3032
// Re-export key types for convenience
3133
pub use oci_image::{
32-
add_referrer, list_images, list_referrers, list_refs, remove_referrer,
33-
remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, OciImage,
34-
OCI_REF_PREFIX,
34+
add_referrer, export_referrers_to_oci_layout, list_images, list_referrers, list_refs,
35+
remove_referrer, remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo,
36+
OciImage, OCI_REF_PREFIX,
3537
};
38+
pub use image::{compute_merged_digest, compute_per_layer_digests};
3639
pub use skopeo::{pull_image, PullResult};
3740

3841
type ContentAndVerity<ObjectID> = (String, ObjectID);
@@ -165,46 +168,23 @@ pub fn write_config<ObjectID: FsVerityHashValue>(
165168
Ok((config_digest, id))
166169
}
167170

168-
/// Seals a container by computing its filesystem fs-verity hash and adding it to the config.
169-
///
170-
/// Creates the complete filesystem from all layers, computes its fs-verity hash, and stores
171-
/// this hash in the container config labels under "containers.composefs.fsverity". This allows
172-
/// the container to be mounted with integrity protection.
173-
///
174-
/// Returns a tuple of (sha256 content hash, fs-verity hash value) for the updated configuration.
175-
pub fn seal<ObjectID: FsVerityHashValue>(
176-
repo: &Arc<Repository<ObjectID>>,
177-
config_name: &str,
178-
config_verity: Option<&ObjectID>,
179-
) -> Result<ContentAndVerity<ObjectID>> {
180-
let (mut config, refs) = open_config(repo, config_name, config_verity)?;
181-
let mut myconfig = config.config().clone().context("no config!")?;
182-
let labels = myconfig.labels_mut().get_or_insert_with(HashMap::new);
183-
let fs = crate::image::create_filesystem(repo, config_name, config_verity)?;
184-
let id = fs.compute_image_id();
185-
labels.insert("containers.composefs.fsverity".to_string(), id.to_hex());
186-
config.set_config(Some(myconfig));
187-
write_config(repo, &config, refs)
188-
}
189171

190-
/// Mounts a sealed container filesystem at the specified mountpoint.
172+
173+
/// Mounts a container filesystem at the specified mountpoint.
191174
///
192-
/// Reads the container configuration to extract the fs-verity hash from the
193-
/// "containers.composefs.fsverity" label, then mounts the corresponding filesystem.
194-
/// The container must have been previously sealed using `seal()`.
175+
/// Computes the merged composefs filesystem from all layers and mounts it.
176+
/// The composefs image must have been previously committed to the repository
177+
/// (e.g., via `cfsctl oci create-image`).
195178
///
196-
/// Returns an error if the container is not sealed or if mounting fails.
179+
/// Returns an error if the image is not found or if mounting fails.
197180
pub fn mount<ObjectID: FsVerityHashValue>(
198181
repo: &Repository<ObjectID>,
199182
name: &str,
200183
mountpoint: &str,
201184
verity: Option<&ObjectID>,
202185
) -> Result<()> {
203-
let (config, _map) = open_config(repo, name, verity)?;
204-
let Some(id) = config.get_config_annotation("containers.composefs.fsverity") else {
205-
bail!("Can only mount sealed containers");
206-
};
207-
repo.mount_at(id, mountpoint)
186+
let id = image::compute_merged_digest(repo, name, verity)?;
187+
repo.mount_at(&id.to_hex(), mountpoint)
208188
}
209189

210190
#[cfg(test)]

0 commit comments

Comments
 (0)