ostree_ext/
ima.rs

1//! Write IMA signatures to an ostree commit
2
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5use crate::objgv::*;
6use anyhow::{Context, Result};
7use camino::Utf8PathBuf;
8use fn_error_context::context;
9use gio::glib;
10use gio::prelude::*;
11use glib::Cast;
12use glib::Variant;
13use gvariant::aligned_bytes::TryAsAligned;
14use gvariant::{gv, Marker, Structure};
15use ostree::gio;
16use rustix::fd::BorrowedFd;
17use std::collections::{BTreeMap, HashMap};
18use std::ffi::CString;
19use std::fs::File;
20use std::io::Seek;
21use std::os::unix::io::AsRawFd;
22use std::process::{Command, Stdio};
23
24/// Extended attribute keys used for IMA.
25const IMA_XATTR: &str = "security.ima";
26
27/// Attributes to configure IMA signatures.
28#[derive(Debug, Clone)]
29pub struct ImaOpts {
30    /// Digest algorithm
31    pub algorithm: String,
32
33    /// Path to IMA key
34    pub key: Utf8PathBuf,
35
36    /// Replace any existing IMA signatures.
37    pub overwrite: bool,
38}
39
40/// Convert a GVariant of type `a(ayay)` to a mutable map
41fn xattrs_to_map(v: &glib::Variant) -> BTreeMap<Vec<u8>, Vec<u8>> {
42    let v = v.data_as_bytes();
43    let v = v.try_as_aligned().unwrap();
44    let v = gv!("a(ayay)").cast(v);
45    let mut map: BTreeMap<Vec<u8>, Vec<u8>> = BTreeMap::new();
46    for e in v.iter() {
47        let (k, v) = e.to_tuple();
48        map.insert(k.into(), v.into());
49    }
50    map
51}
52
53/// Create a new GVariant of type a(ayay).  This is used by OSTree's extended attributes.
54pub(crate) fn new_variant_a_ayay<'a, T: 'a + AsRef<[u8]>>(
55    items: impl IntoIterator<Item = (T, T)>,
56) -> glib::Variant {
57    let children = items.into_iter().map(|(a, b)| {
58        let a = a.as_ref();
59        let b = b.as_ref();
60        Variant::tuple_from_iter([a.to_variant(), b.to_variant()])
61    });
62    Variant::array_from_iter::<(&[u8], &[u8])>(children)
63}
64
65struct CommitRewriter<'a> {
66    repo: &'a ostree::Repo,
67    ima: &'a ImaOpts,
68    tempdir: tempfile::TempDir,
69    /// Maps content object sha256 hex string to a signed object sha256 hex string
70    rewritten_files: HashMap<String, String>,
71}
72
73#[allow(unsafe_code)]
74#[context("Gathering xattr {}", k)]
75fn steal_xattr(f: &File, k: &str) -> Result<Vec<u8>> {
76    let k = &CString::new(k)?;
77    unsafe {
78        let k = k.as_ptr() as *const _;
79        let r = libc::fgetxattr(f.as_raw_fd(), k, std::ptr::null_mut(), 0);
80        if r < 0 {
81            return Err(std::io::Error::last_os_error().into());
82        }
83        let sz: usize = r.try_into()?;
84        let mut buf = vec![0u8; sz];
85        let r = libc::fgetxattr(f.as_raw_fd(), k, buf.as_mut_ptr() as *mut _, sz);
86        if r < 0 {
87            return Err(std::io::Error::last_os_error().into());
88        }
89        let r = libc::fremovexattr(f.as_raw_fd(), k);
90        if r < 0 {
91            return Err(std::io::Error::last_os_error().into());
92        }
93        Ok(buf)
94    }
95}
96
97impl<'a> CommitRewriter<'a> {
98    fn new(repo: &'a ostree::Repo, ima: &'a ImaOpts) -> Result<Self> {
99        Ok(Self {
100            repo,
101            ima,
102            tempdir: tempfile::tempdir_in(format!("/proc/self/fd/{}/tmp", repo.dfd()))?,
103            rewritten_files: Default::default(),
104        })
105    }
106
107    /// Use `evmctl` to generate an IMA signature on a file, then
108    /// scrape the xattr value out of it (removing it).
109    ///
110    /// evmctl can write a separate file but it picks the name...so
111    /// we do this hacky dance of `--xattr-user` instead.
112    #[allow(unsafe_code)]
113    #[context("IMA signing object")]
114    fn ima_sign(&self, instream: &gio::InputStream) -> Result<HashMap<Vec<u8>, Vec<u8>>> {
115        let mut tempf = tempfile::NamedTempFile::new_in(self.tempdir.path())?;
116        // If we're operating on a bare repo, we can clone the file (copy_file_range) directly.
117        if let Ok(instream) = instream.clone().downcast::<gio::UnixInputStream>() {
118            use cap_std_ext::cap_std::io_lifetimes::AsFilelike;
119            // View the fd as a File
120            let instream_fd = unsafe { BorrowedFd::borrow_raw(instream.as_raw_fd()) };
121            let instream_fd = instream_fd.as_filelike_view::<std::fs::File>();
122            std::io::copy(&mut (&*instream_fd), tempf.as_file_mut())?;
123        } else {
124            // If we're operating on an archive repo, then we need to uncompress
125            // and recompress...
126            let mut instream = instream.clone().into_read();
127            let _n = std::io::copy(&mut instream, tempf.as_file_mut())?;
128        }
129        tempf.seek(std::io::SeekFrom::Start(0))?;
130
131        let mut proc = Command::new("evmctl");
132        proc.current_dir(self.tempdir.path())
133            .stdout(Stdio::null())
134            .stderr(Stdio::piped())
135            .args(["ima_sign", "--xattr-user", "--key", self.ima.key.as_str()])
136            .args(["--hashalgo", self.ima.algorithm.as_str()])
137            .arg(tempf.path().file_name().unwrap());
138        let status = proc.output().context("Spawning evmctl")?;
139        if !status.status.success() {
140            return Err(anyhow::anyhow!(
141                "evmctl failed: {:?}\n{}",
142                status.status,
143                String::from_utf8_lossy(&status.stderr),
144            ));
145        }
146        let mut r = HashMap::new();
147        let user_k = IMA_XATTR.replace("security.", "user.");
148        let v = steal_xattr(tempf.as_file(), user_k.as_str())?;
149        r.insert(Vec::from(IMA_XATTR.as_bytes()), v);
150        Ok(r)
151    }
152
153    #[context("Content object {}", checksum)]
154    fn map_file(&mut self, checksum: &str) -> Result<Option<String>> {
155        let cancellable = gio::Cancellable::NONE;
156        let (instream, meta, xattrs) = self.repo.load_file(checksum, cancellable)?;
157        let instream = if let Some(i) = instream {
158            i
159        } else {
160            return Ok(None);
161        };
162        let mut xattrs = xattrs_to_map(&xattrs);
163        let existing_sig = xattrs.remove(IMA_XATTR.as_bytes());
164        if existing_sig.is_some() && !self.ima.overwrite {
165            return Ok(None);
166        }
167
168        // Now inject the IMA xattr
169        let xattrs = {
170            let signed = self.ima_sign(&instream)?;
171            xattrs.extend(signed);
172            new_variant_a_ayay(&xattrs)
173        };
174        // Now reload the input stream
175        let (instream, _, _) = self.repo.load_file(checksum, cancellable)?;
176        let instream = instream.unwrap();
177        let (ostream, size) =
178            ostree::raw_file_to_content_stream(&instream, &meta, Some(&xattrs), cancellable)?;
179        let new_checksum = self
180            .repo
181            .write_content(None, &ostream, size, cancellable)?
182            .to_hex();
183
184        Ok(Some(new_checksum))
185    }
186
187    /// Write a dirtree object.
188    fn map_dirtree(&mut self, checksum: &str) -> Result<String> {
189        let src = &self
190            .repo
191            .load_variant(ostree::ObjectType::DirTree, checksum)?;
192        let src = src.data_as_bytes();
193        let src = src.try_as_aligned()?;
194        let src = gv_dirtree!().cast(src);
195        let (files, dirs) = src.to_tuple();
196
197        // A reusable buffer to avoid heap allocating these
198        let mut hexbuf = [0u8; 64];
199
200        let mut new_files = Vec::new();
201        for file in files {
202            let (name, csum) = file.to_tuple();
203            let name = name.to_str();
204            hex::encode_to_slice(csum, &mut hexbuf)?;
205            let checksum = std::str::from_utf8(&hexbuf)?;
206            if let Some(mapped) = self.rewritten_files.get(checksum) {
207                new_files.push((name, hex::decode(mapped)?));
208            } else if let Some(mapped) = self.map_file(checksum)? {
209                let mapped_bytes = hex::decode(&mapped)?;
210                self.rewritten_files.insert(checksum.into(), mapped);
211                new_files.push((name, mapped_bytes));
212            } else {
213                new_files.push((name, Vec::from(csum)));
214            }
215        }
216
217        let mut new_dirs = Vec::new();
218        for item in dirs {
219            let (name, contents_csum, meta_csum_bytes) = item.to_tuple();
220            let name = name.to_str();
221            hex::encode_to_slice(contents_csum, &mut hexbuf)?;
222            let contents_csum = std::str::from_utf8(&hexbuf)?;
223            let mapped = self.map_dirtree(contents_csum)?;
224            let mapped = hex::decode(mapped)?;
225            new_dirs.push((name, mapped, meta_csum_bytes));
226        }
227
228        let new_dirtree = (new_files, new_dirs).to_variant();
229
230        let mapped = self
231            .repo
232            .write_metadata(
233                ostree::ObjectType::DirTree,
234                None,
235                &new_dirtree,
236                gio::Cancellable::NONE,
237            )?
238            .to_hex();
239
240        Ok(mapped)
241    }
242
243    /// Write a commit object.
244    #[context("Mapping {}", rev)]
245    fn map_commit(&mut self, rev: &str) -> Result<String> {
246        let checksum = self.repo.require_rev(rev)?;
247        let cancellable = gio::Cancellable::NONE;
248        let (commit_v, _) = self.repo.load_commit(&checksum)?;
249        let commit_v = &commit_v;
250
251        let commit_bytes = commit_v.data_as_bytes();
252        let commit_bytes = commit_bytes.try_as_aligned()?;
253        let commit = gv_commit!().cast(commit_bytes);
254        let commit = commit.to_tuple();
255        let contents = &hex::encode(commit.6);
256
257        let new_dt = self.map_dirtree(contents)?;
258
259        let n_parts = 8;
260        let mut parts = Vec::with_capacity(n_parts);
261        for i in 0..n_parts {
262            parts.push(commit_v.child_value(i));
263        }
264        let new_dt = hex::decode(new_dt)?;
265        parts[6] = new_dt.to_variant();
266        let new_commit = Variant::tuple_from_iter(&parts);
267
268        let new_commit_checksum = self
269            .repo
270            .write_metadata(ostree::ObjectType::Commit, None, &new_commit, cancellable)?
271            .to_hex();
272
273        Ok(new_commit_checksum)
274    }
275}
276
277/// Given an OSTree commit and an IMA configuration, generate a new commit object with IMA signatures.
278///
279/// The generated commit object will inherit all metadata from the existing commit object
280/// such as version, etc.
281///
282/// This function does not create an ostree transaction; it's recommended to use outside the call
283/// to this function.
284pub fn ima_sign(repo: &ostree::Repo, ostree_ref: &str, opts: &ImaOpts) -> Result<String> {
285    let writer = &mut CommitRewriter::new(repo, opts)?;
286    writer.map_commit(ostree_ref)
287}