about summary refs log tree commit diff
path: root/tvix/nix-compat/src/narinfo
diff options
context:
space:
mode:
Diffstat (limited to 'tvix/nix-compat/src/narinfo')
-rw-r--r--tvix/nix-compat/src/narinfo/fingerprint.rs50
-rw-r--r--tvix/nix-compat/src/narinfo/mod.rs527
-rw-r--r--tvix/nix-compat/src/narinfo/public_keys.rs152
-rw-r--r--tvix/nix-compat/src/narinfo/signature.rs184
4 files changed, 913 insertions, 0 deletions
diff --git a/tvix/nix-compat/src/narinfo/fingerprint.rs b/tvix/nix-compat/src/narinfo/fingerprint.rs
new file mode 100644
index 0000000000..3e02aca571
--- /dev/null
+++ b/tvix/nix-compat/src/narinfo/fingerprint.rs
@@ -0,0 +1,50 @@
+use crate::{nixbase32, store_path::StorePathRef};
+
+/// Computes the fingerprint string for certain fields in a [super::NarInfo].
+/// This fingerprint is signed by an ed25519 key, and in the case of a Nix HTTP
+/// Binary cache, included in the NARInfo files served from there.
+pub fn fingerprint<'a, R: Iterator<Item = &'a StorePathRef<'a>>>(
+    store_path: &StorePathRef,
+    nar_sha256: &[u8; 32],
+    nar_size: u64,
+    references: R,
+) -> String {
+    format!(
+        "1;{};sha256:{};{};{}",
+        store_path.to_absolute_path(),
+        nixbase32::encode(nar_sha256),
+        nar_size,
+        // references are absolute paths, joined with `,`.
+        references
+            .map(|r| r.to_absolute_path())
+            .collect::<Vec<String>>()
+            .join(",")
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::narinfo::NarInfo;
+
+    const NARINFO_STR: &str = r#"StorePath: /nix/store/syd87l2rxw8cbsxmxl853h0r6pdwhwjr-curl-7.82.0-bin
+URL: nar/05ra3y72i3qjri7xskf9qj8kb29r6naqy1sqpbs3azi3xcigmj56.nar.xz
+Compression: xz
+FileHash: sha256:05ra3y72i3qjri7xskf9qj8kb29r6naqy1sqpbs3azi3xcigmj56
+FileSize: 68852
+NarHash: sha256:1b4sb93wp679q4zx9k1ignby1yna3z7c4c2ri3wphylbc2dwsys0
+NarSize: 196040
+References: 0jqd0rlxzra1rs38rdxl43yh6rxchgc6-curl-7.82.0 6w8g7njm4mck5dmjxws0z1xnrxvl81xa-glibc-2.34-115 j5jxw3iy7bbz4a57fh9g2xm2gxmyal8h-zlib-1.2.12 yxvjs9drzsphm9pcf42a4byzj1kb9m7k-openssl-1.1.1n
+Deriver: 5rwxzi7pal3qhpsyfc16gzkh939q1np6-curl-7.82.0.drv
+Sig: cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==
+Sig: test1:519iiVLx/c4Rdt5DNt6Y2Jm6hcWE9+XY69ygiWSZCNGVcmOcyL64uVAJ3cV8vaTusIZdbTnYo9Y7vDNeTmmMBQ==
+"#;
+
+    #[test]
+    fn fingerprint() {
+        let parsed = NarInfo::parse(NARINFO_STR).expect("must parse");
+        assert_eq!(
+            "1;/nix/store/syd87l2rxw8cbsxmxl853h0r6pdwhwjr-curl-7.82.0-bin;sha256:1b4sb93wp679q4zx9k1ignby1yna3z7c4c2ri3wphylbc2dwsys0;196040;/nix/store/0jqd0rlxzra1rs38rdxl43yh6rxchgc6-curl-7.82.0,/nix/store/6w8g7njm4mck5dmjxws0z1xnrxvl81xa-glibc-2.34-115,/nix/store/j5jxw3iy7bbz4a57fh9g2xm2gxmyal8h-zlib-1.2.12,/nix/store/yxvjs9drzsphm9pcf42a4byzj1kb9m7k-openssl-1.1.1n",
+            parsed.fingerprint()
+        );
+    }
+}
diff --git a/tvix/nix-compat/src/narinfo/mod.rs b/tvix/nix-compat/src/narinfo/mod.rs
new file mode 100644
index 0000000000..b1c10bceb2
--- /dev/null
+++ b/tvix/nix-compat/src/narinfo/mod.rs
@@ -0,0 +1,527 @@
+//! NAR info files describe a store path in a traditional Nix binary cache.
+//! Over the wire, they are formatted as "Key: value" pairs separated by newlines.
+//!
+//! It contains four kinds of information:
+//! 1. the description of the store path itself
+//!    * store path prefix, digest, and name
+//!    * NAR hash and size
+//!    * references
+//! 2. authenticity information
+//!    * zero or more signatures over that description
+//!    * an optional [CAHash] for content-addressed paths (fixed outputs, sources, and derivations)
+//! 3. derivation metadata
+//!    * deriver (the derivation that produced this path)
+//!    * system (the system value of that derivation)
+//! 4. cache-specific information
+//!    * URL of the compressed NAR, relative to the NAR info file
+//!    * compression algorithm used for the NAR
+//!    * hash and size of the compressed NAR
+
+use bitflags::bitflags;
+use data_encoding::HEXLOWER;
+use std::{
+    fmt::{self, Display},
+    mem,
+};
+
+use crate::{nixbase32, nixhash::CAHash, store_path::StorePathRef};
+
+mod fingerprint;
+mod public_keys;
+mod signature;
+
+pub use fingerprint::fingerprint;
+
+pub use public_keys::{Error as PubKeyError, PubKey};
+pub use signature::{Error as SignatureError, Signature};
+
+#[derive(Debug)]
+pub struct NarInfo<'a> {
+    pub flags: Flags,
+    // core (authenticated, but unverified here)
+    /// Store path described by this [NarInfo]
+    pub store_path: StorePathRef<'a>,
+    /// SHA-256 digest of the NAR file
+    pub nar_hash: [u8; 32],
+    /// Size of the NAR file in bytes
+    pub nar_size: u64,
+    /// Store paths known to be referenced by the contents
+    pub references: Vec<StorePathRef<'a>>,
+    // authenticity
+    /// Ed25519 signature over the path fingerprint
+    pub signatures: Vec<Signature<'a>>,
+    /// Content address (for content-defined paths)
+    pub ca: Option<CAHash>,
+    // derivation metadata
+    /// Nix system triple of [NarInfo::deriver]
+    pub system: Option<&'a str>,
+    /// Store path of the derivation that produced this. The last .drv suffix is stripped.
+    pub deriver: Option<StorePathRef<'a>>,
+    // cache-specific untrusted metadata
+    /// Relative URL of the compressed NAR file
+    pub url: &'a str,
+    /// Compression method of the NAR file
+    /// `None` means `Compression: none`.
+    ///
+    /// Nix interprets a missing `Compression` field as `Some("bzip2")`,
+    /// so we do as well. We haven't found any examples of this in the
+    /// wild, not even in the cache.nixos.org dataset.
+    pub compression: Option<&'a str>,
+    /// SHA-256 digest of the file at `url`
+    pub file_hash: Option<[u8; 32]>,
+    /// Size of the file at `url` in bytes
+    pub file_size: Option<u64>,
+}
+
+bitflags! {
+    /// TODO(edef): be conscious of these when roundtripping
+    #[derive(Debug, Copy, Clone)]
+    pub struct Flags: u8 {
+        const UNKNOWN_FIELD = 1 << 0;
+        const COMPRESSION_DEFAULT = 1 << 1;
+        // Format quirks encountered in the cache.nixos.org dataset
+        const REFERENCES_OUT_OF_ORDER = 1 << 2;
+        const NAR_HASH_HEX = 1 << 3;
+    }
+}
+
+impl<'a> NarInfo<'a> {
+    pub fn parse(input: &'a str) -> Result<Self, Error> {
+        let mut flags = Flags::empty();
+        let mut store_path = None;
+        let mut url = None;
+        let mut compression = None;
+        let mut file_hash = None;
+        let mut file_size = None;
+        let mut nar_hash = None;
+        let mut nar_size = None;
+        let mut references = None;
+        let mut system = None;
+        let mut deriver = None;
+        let mut signatures = vec![];
+        let mut ca = None;
+
+        for line in input.lines() {
+            let (tag, val) = line
+                .split_once(':')
+                .ok_or_else(|| Error::InvalidLine(line.to_string()))?;
+
+            let val = val
+                .strip_prefix(' ')
+                .ok_or_else(|| Error::InvalidLine(line.to_string()))?;
+
+            match tag {
+                "StorePath" => {
+                    let val = val
+                        .strip_prefix("/nix/store/")
+                        .ok_or(Error::InvalidStorePath(
+                            crate::store_path::Error::MissingStoreDir,
+                        ))?;
+                    let val = StorePathRef::from_bytes(val.as_bytes())
+                        .map_err(Error::InvalidStorePath)?;
+
+                    if store_path.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "URL" => {
+                    if val.is_empty() {
+                        return Err(Error::EmptyField(tag.to_string()));
+                    }
+
+                    if url.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "Compression" => {
+                    if val.is_empty() {
+                        return Err(Error::EmptyField(tag.to_string()));
+                    }
+
+                    if compression.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "FileHash" => {
+                    let val = val
+                        .strip_prefix("sha256:")
+                        .ok_or_else(|| Error::MissingPrefixForHash(tag.to_string()))?;
+                    let val = nixbase32::decode_fixed::<32>(val)
+                        .map_err(|e| Error::UnableToDecodeHash(tag.to_string(), e))?;
+
+                    if file_hash.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "FileSize" => {
+                    let val = val
+                        .parse::<u64>()
+                        .map_err(|_| Error::UnableToParseSize(tag.to_string(), val.to_string()))?;
+
+                    if file_size.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "NarHash" => {
+                    let val = val
+                        .strip_prefix("sha256:")
+                        .ok_or_else(|| Error::MissingPrefixForHash(tag.to_string()))?;
+
+                    let val = if val.len() != HEXLOWER.encode_len(32) {
+                        nixbase32::decode_fixed::<32>(val)
+                    } else {
+                        flags |= Flags::NAR_HASH_HEX;
+
+                        let val = val.as_bytes();
+                        let mut buf = [0u8; 32];
+
+                        HEXLOWER
+                            .decode_mut(val, &mut buf)
+                            .map_err(|e| e.error)
+                            .map(|_| buf)
+                    };
+
+                    let val = val.map_err(|e| Error::UnableToDecodeHash(tag.to_string(), e))?;
+
+                    if nar_hash.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "NarSize" => {
+                    let val = val
+                        .parse::<u64>()
+                        .map_err(|_| Error::UnableToParseSize(tag.to_string(), val.to_string()))?;
+
+                    if nar_size.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "References" => {
+                    let val: Vec<StorePathRef> = if !val.is_empty() {
+                        let mut prev = "";
+                        val.split(' ')
+                            .enumerate()
+                            .map(|(i, s)| {
+                                // TODO(edef): track *duplicates* if this occurs
+                                if mem::replace(&mut prev, s) >= s {
+                                    flags |= Flags::REFERENCES_OUT_OF_ORDER;
+                                }
+
+                                StorePathRef::from_bytes(s.as_bytes())
+                                    .map_err(|err| Error::InvalidReference(i, err))
+                            })
+                            .collect::<Result<_, _>>()?
+                    } else {
+                        vec![]
+                    };
+
+                    if references.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "System" => {
+                    if val.is_empty() {
+                        return Err(Error::EmptyField(tag.to_string()));
+                    }
+
+                    if system.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                "Deriver" => {
+                    match val.strip_suffix(".drv") {
+                        Some(val) => {
+                            let val = StorePathRef::from_bytes(val.as_bytes())
+                                .map_err(Error::InvalidDeriverStorePath)?;
+
+                            if deriver.replace(val).is_some() {
+                                return Err(Error::DuplicateField(tag.to_string()));
+                            }
+                        }
+                        None => {
+                            return Err(Error::InvalidDeriverStorePathMissingSuffix);
+                        }
+                    };
+                }
+                "Sig" => {
+                    let val = Signature::parse(val)
+                        .map_err(|e| Error::UnableToParseSignature(signatures.len(), e))?;
+
+                    signatures.push(val);
+                }
+                "CA" => {
+                    let val = CAHash::from_nix_hex_str(val)
+                        .ok_or_else(|| Error::UnableToParseCA(val.to_string()))?;
+
+                    if ca.replace(val).is_some() {
+                        return Err(Error::DuplicateField(tag.to_string()));
+                    }
+                }
+                _ => {
+                    flags |= Flags::UNKNOWN_FIELD;
+                }
+            }
+        }
+
+        Ok(NarInfo {
+            store_path: store_path.ok_or(Error::MissingField("StorePath"))?,
+            nar_hash: nar_hash.ok_or(Error::MissingField("NarHash"))?,
+            nar_size: nar_size.ok_or(Error::MissingField("NarSize"))?,
+            references: references.ok_or(Error::MissingField("References"))?,
+            signatures,
+            ca,
+            system,
+            deriver,
+            url: url.ok_or(Error::MissingField("URL"))?,
+            compression: match compression {
+                Some("none") => None,
+                None => {
+                    flags |= Flags::COMPRESSION_DEFAULT;
+                    Some("bzip2")
+                }
+                _ => compression,
+            },
+            file_hash,
+            file_size,
+            flags,
+        })
+    }
+
+    /// Computes the fingerprint string for certain fields in this [NarInfo].
+    /// This fingerprint is signed in [self.signatures].
+    pub fn fingerprint(&self) -> String {
+        fingerprint(
+            &self.store_path,
+            &self.nar_hash,
+            self.nar_size,
+            self.references.iter(),
+        )
+    }
+}
+
+impl Display for NarInfo<'_> {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        writeln!(w, "StorePath: /nix/store/{}", self.store_path)?;
+        writeln!(w, "URL: {}", self.url)?;
+
+        if let Some(compression) = self.compression {
+            writeln!(w, "Compression: {compression}")?;
+        }
+
+        if let Some(file_hash) = self.file_hash {
+            writeln!(w, "FileHash: sha256:{}", nixbase32::encode(&file_hash),)?;
+        }
+
+        if let Some(file_size) = self.file_size {
+            writeln!(w, "FileSize: {file_size}")?;
+        }
+
+        writeln!(w, "NarHash: sha256:{}", nixbase32::encode(&self.nar_hash),)?;
+        writeln!(w, "NarSize: {}", self.nar_size)?;
+
+        write!(w, "References:")?;
+        if self.references.is_empty() {
+            write!(w, " ")?;
+        } else {
+            for path in &self.references {
+                write!(w, " {path}")?;
+            }
+        }
+        writeln!(w)?;
+
+        if let Some(deriver) = &self.deriver {
+            writeln!(w, "Deriver: {deriver}.drv")?;
+        }
+
+        if let Some(system) = self.system {
+            writeln!(w, "System: {system}")?;
+        }
+
+        for sig in &self.signatures {
+            writeln!(w, "Sig: {sig}")?;
+        }
+
+        if let Some(ca) = &self.ca {
+            writeln!(w, "CA: {}", ca.to_nix_nixbase32_string())?;
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+    #[error("duplicate field: {0}")]
+    DuplicateField(String),
+
+    #[error("missing field: {0}")]
+    MissingField(&'static str),
+
+    #[error("invalid line: {0}")]
+    InvalidLine(String),
+
+    #[error("invalid StorePath: {0}")]
+    InvalidStorePath(crate::store_path::Error),
+
+    #[error("field {0} may not be empty string")]
+    EmptyField(String),
+
+    #[error("invalid {0}: {1}")]
+    UnableToParseSize(String, String),
+
+    #[error("unable to parse #{0} reference: {1}")]
+    InvalidReference(usize, crate::store_path::Error),
+
+    #[error("invalid Deriver store path: {0}")]
+    InvalidDeriverStorePath(crate::store_path::Error),
+
+    #[error("invalid Deriver store path, must end with .drv")]
+    InvalidDeriverStorePathMissingSuffix,
+
+    #[error("missing prefix for {0}")]
+    MissingPrefixForHash(String),
+
+    #[error("unable to decode {0}: {1}")]
+    UnableToDecodeHash(String, data_encoding::DecodeError),
+
+    #[error("unable to parse signature #{0}: {1}")]
+    UnableToParseSignature(usize, SignatureError),
+
+    #[error("unable to parse CA field: {0}")]
+    UnableToParseCA(String),
+}
+
+#[cfg(test)]
+mod test {
+    use hex_literal::hex;
+    use lazy_static::lazy_static;
+    use pretty_assertions::assert_eq;
+    use std::{io, str};
+
+    use crate::{
+        nixhash::{CAHash, NixHash},
+        store_path::StorePathRef,
+    };
+
+    use super::{Flags, NarInfo};
+
+    lazy_static! {
+        static ref CASES: &'static [&'static str] = {
+            let data = zstd::decode_all(io::Cursor::new(include_bytes!(
+                "../../testdata/narinfo.zst"
+            )))
+            .unwrap();
+            let data = str::from_utf8(Vec::leak(data)).unwrap();
+            Vec::leak(
+                data.split_inclusive("\n\n")
+                    .map(|s| s.strip_suffix('\n').unwrap())
+                    .collect::<Vec<_>>(),
+            )
+        };
+    }
+
+    #[test]
+    fn roundtrip() {
+        for &input in *CASES {
+            let parsed = NarInfo::parse(input).expect("should parse");
+            let output = format!("{parsed}");
+            assert_eq!(input, output, "should roundtrip");
+        }
+    }
+
+    #[test]
+    fn references_out_of_order() {
+        let parsed = NarInfo::parse(
+            r#"StorePath: /nix/store/xi429w4ddvb1r77978hm7jfb2jsn559r-gcc-3.4.6
+URL: nar/1hr09cgkyw1hcsfkv5qp5jlpmf2mqrkrqs3xj5zklq9c1h9544ff.nar.bz2
+Compression: bzip2
+FileHash: sha256:1hr09cgkyw1hcsfkv5qp5jlpmf2mqrkrqs3xj5zklq9c1h9544ff
+FileSize: 4006
+NarHash: sha256:0ik9mpqxpd9hv325hdblj2nawqj5w7951qdyy8ikxgwr6fq7m11c
+NarSize: 21264
+References: a8922c0h87iilxzzvwn2hmv8x210aqb9-glibc-2.7 7w2acjgalb0cm7b3bg8yswza4l7iil9y-binutils-2.18 mm631h09mj964hm9q04l5fd8vw12j1mm-bash-3.2-p39 nx2zs2qd6snfcpzw4a0jnh26z9m0yihz-gcc-3.4.6 xi429w4ddvb1r77978hm7jfb2jsn559r-gcc-3.4.6
+Deriver: 2dzpn70c1hawczwhg9aavqk18zp9zsva-gcc-3.4.6.drv
+Sig: cache.nixos.org-1:o1DTsjCz0PofLJ216P2RBuSulI8BAb6zHxWE4N+tzlcELk5Uk/GO2SCxWTRN5wJutLZZ+cHTMdWqOHF88KGQDg==
+"#).expect("should parse");
+
+        assert!(parsed.flags.contains(Flags::REFERENCES_OUT_OF_ORDER));
+        assert_eq!(
+            vec![
+                "a8922c0h87iilxzzvwn2hmv8x210aqb9-glibc-2.7",
+                "7w2acjgalb0cm7b3bg8yswza4l7iil9y-binutils-2.18",
+                "mm631h09mj964hm9q04l5fd8vw12j1mm-bash-3.2-p39",
+                "nx2zs2qd6snfcpzw4a0jnh26z9m0yihz-gcc-3.4.6",
+                "xi429w4ddvb1r77978hm7jfb2jsn559r-gcc-3.4.6"
+            ],
+            parsed
+                .references
+                .iter()
+                .map(StorePathRef::to_string)
+                .collect::<Vec<_>>(),
+        );
+    }
+
+    #[test]
+    fn ca_nar_hash_sha1() {
+        let parsed = NarInfo::parse(
+            r#"StorePath: /nix/store/k20pahypzvr49fy82cw5sx72hdfg3qcr-texlive-hyphenex-37354
+URL: nar/0i5biw0g01514llhfswxy6xfav8lxxdq1xg6ik7hgsqbpw0f06yi.nar.xz
+Compression: xz
+FileHash: sha256:0i5biw0g01514llhfswxy6xfav8lxxdq1xg6ik7hgsqbpw0f06yi
+FileSize: 7120
+NarHash: sha256:0h1bm4sj1cnfkxgyhvgi8df1qavnnv94sd0v09wcrm971602shfg
+NarSize: 22552
+References: 
+Sig: cache.nixos.org-1:u01BybwQhyI5H1bW1EIWXssMDhDDIvXOG5uh8Qzgdyjz6U1qg6DHhMAvXZOUStIj6X5t4/ufFgR8i3fjf0bMAw==
+CA: fixed:r:sha1:1ak1ymbmsfx7z8kh09jzkr3a4dvkrfjw
+"#).expect("should parse");
+
+        assert_eq!(
+            parsed.ca,
+            Some(CAHash::Nar(NixHash::Sha1(hex!(
+                "5cba3c77236ae4f9650270a27fbad375551fa60a"
+            ))))
+        );
+    }
+
+    #[test]
+    fn compression_default() {
+        // This doesn't exist as such in cache.nixos.org.
+        // We explicitly removed the compression field for the sake of this test.
+        let parsed = NarInfo::parse(r#"StorePath: /nix/store/a1jjalr4csx9hcga7fnm122aqabrjnch-digikam-2.6.0
+URL: nar/1fzimfnvq2k8b40n4g54abmncpx2ddckh6qlb77pgq6xiysyil69.nar.bz2
+FileHash: sha256:1fzimfnvq2k8b40n4g54abmncpx2ddckh6qlb77pgq6xiysyil69
+FileSize: 43503778
+NarHash: sha256:0zpbbwipqzr5p8mlpag9wrsp5hlaxkq7gax5jj0hg3vvdziypcw5
+NarSize: 100658640
+References: 0izkyk7bq2ag9393nvnhgm87p75cq09w-liblqr-1-0.4.1 1cslpgyb7vb30inj3210jv6agqv42jxz-qca-2.0.3 1sya3bwjxkzpkmwn67gfzp4gz4g62l36-libXrandr-1.3.1 26yxdaa9z0ma5sgw02i670rsqnl57crs-glib-2.30.3 27lnjh99236kmhbpc5747599zcymfzmg-qt-4.8.2 2v6x378vcfvyxilkvihs60zha54z2x2y-qjson-0.7.1 45hgr3fbnr45n795hn2x7hsymp0h2j2m-libjpeg-8c 4kw1b212s80ap2iyibxrimcqb5imhfj7-libkexiv2-4.7.4 7dvylm5crlc0sfafcc0n46mb5ch67q0j-glibc-2.13 a05cbh1awjbl1rbyb2ynyf4k42v5a9a7-boost-1.47.0 a1jjalr4csx9hcga7fnm122aqabrjnch-digikam-2.6.0 aav5ffg8wlnilgnvdb2jnrv2aam4zmmz-perl-5.14.2 ab0m9h30nsr13w48qriv0k350kmwx567-kdelibs-4.7.4 avffkd49cqvpwdkzry8bn69dkbw4cy29-lensfun-0.2.5 cy8rl8h4yp2j3h8987vkklg328q3wmjz-gcc-4.6.3 dmmh5ihyg1r2dm4azgsfj2kprj92czlg-libSM-1.2.0 fl56j5n4shfw9c0r6vs2i4f1h9zx5kac-soprano-2.7.6 g15cmvh15ggdjcwapskngv20q4yhix40-jasper-1.900.1 i04maxd0din6v92rnqcwl9yra0kl2vk5-marble-4.7.4 kqjjb3m26rdddwwwkk8v45821aps877k-libICE-1.0.7 lxz9r135wkndvi642z4bjgmvyypsgirb-libtiff-3.9.4 m9c8i0a6cl30lcqp654dqkbag3wjmd00-libX11-1.4.1 mpnj4k2ijrgyfkh48fg96nzcmklfh5pl-coreutils-8.15 nppljblap477s0893c151lyq7r7n5v1q-zlib-1.2.7 nw9mdbyp8kyn3v4vkdzq0gsnqbc4mnx3-expat-2.0.1 p1a0dn931mzdkvj6h5yzshbmgxba5r0z-libgphoto2-2.4.11 pvjj07xa1cfkad3gwk376nzdrgknbcqm-mesa-7.11.2 pzcxag98jqccp9ycbxknyh0w95pgnsk4-lcms-1.19 qfi5pgds33kg6vlnxsmj0hyl74vcmyiz-libpng-1.5.10 scm6bj86s3qh3s3x0b9ayjp6755p4q86-mysql-5.1.54 sd23qspcyg385va0lr35xgz3hvlqphg6-libkipi-4.7.4 svmbrhc6kzfzakv20a7zrfl6kbr5mfpq-kdepimlibs-4.7.4 v7kh3h7xfwjz4hgffg3gwrfzjff9bw9d-bash-4.2-p24 vi17f22064djgpk0w248da348q8gxkww-libkdcraw-4.7.4 wkjdzmj3z4dcbsc9f833zs6krdgg2krk-phonon-4.6.0 xf3i3awqi0035ixy2qyb6hk4c92r3vrn-opencv-2.4.2 y1vr0nz8i59x59501020nh2k1dw3bhwq-libusb-0.1.12 yf3hin2hb6i08n7zrk8g3acy54rhg9bp-libXext-1.2.0
+Deriver: la77dr44phk5m5jnl4dvk01cwpykyw9s-digikam-2.6.0.drv
+System: i686-linux
+Sig: cache.nixos.org-1:92fl0i5q7EyegCj5Yf4L0bENkWuVAtgveiRcTEEUH0P6HvCE1xFcPbz/0Pf6Np+K1LPzHK+s5RHOmVoxRsvsDg==
+"#).expect("should parse");
+
+        assert!(parsed.flags.contains(Flags::COMPRESSION_DEFAULT));
+        assert_eq!(parsed.compression, Some("bzip2"));
+    }
+
+    #[test]
+    fn nar_hash_hex() {
+        let parsed = NarInfo::parse(r#"StorePath: /nix/store/0vpqfxbkx0ffrnhbws6g9qwhmliksz7f-perl-HTTP-Cookies-6.01
+URL: nar/1rv1m9inydm1r4krw8hmwg1hs86d0nxddd1pbhihx7l7fycjvfk3.nar.xz
+Compression: xz
+FileHash: sha256:1rv1m9inydm1r4krw8hmwg1hs86d0nxddd1pbhihx7l7fycjvfk3
+FileSize: 19912
+NarHash: sha256:60adfd293a4d81ad7cd7e47263cbb3fc846309ef91b154a08ba672b558f94ff3
+NarSize: 45840
+References: 0vpqfxbkx0ffrnhbws6g9qwhmliksz7f-perl-HTTP-Cookies-6.01 9vrhbib2lxd9pjlg6fnl5b82gblidrcr-perl-HTTP-Message-6.06 wy20zslqxzxxfpzzk0rajh41d7a6mlnf-perl-HTTP-Date-6.02
+Deriver: fb4ihlq3psnsjq95mvvs49rwpplpc8zj-perl-HTTP-Cookies-6.01.drv
+Sig: cache.nixos.org-1:HhaiY36Uk3XV1JGe9d9xHnzAapqJXprU1YZZzSzxE97jCuO5RR7vlG2kF7MSC5thwRyxAtdghdSz3AqFi+QSCw==
+"#).expect("should parse");
+
+        assert!(parsed.flags.contains(Flags::NAR_HASH_HEX));
+        assert_eq!(
+            hex!("60adfd293a4d81ad7cd7e47263cbb3fc846309ef91b154a08ba672b558f94ff3"),
+            parsed.nar_hash,
+        );
+    }
+}
diff --git a/tvix/nix-compat/src/narinfo/public_keys.rs b/tvix/nix-compat/src/narinfo/public_keys.rs
new file mode 100644
index 0000000000..27dd90e096
--- /dev/null
+++ b/tvix/nix-compat/src/narinfo/public_keys.rs
@@ -0,0 +1,152 @@
+//! This module defines data structures and parsers for the public key format
+//! used inside Nix to verify signatures on .narinfo files.
+
+use std::fmt::Display;
+
+use data_encoding::BASE64;
+use ed25519_dalek::{VerifyingKey, PUBLIC_KEY_LENGTH};
+
+use super::Signature;
+
+/// This represents a ed25519 public key and "name".
+/// These are normally passed in the `trusted-public-keys` Nix config option,
+/// and consist of a name and base64-encoded ed25519 pubkey, separated by a `:`.
+#[derive(Debug)]
+pub struct PubKey {
+    name: String,
+    verifying_key: VerifyingKey,
+}
+
+impl PubKey {
+    pub fn new(name: String, verifying_key: VerifyingKey) -> Self {
+        Self {
+            name,
+            verifying_key,
+        }
+    }
+
+    pub fn parse(input: &str) -> Result<Self, Error> {
+        let (name, bytes64) = input.split_once(':').ok_or(Error::MissingSeparator)?;
+
+        if name.is_empty()
+            || !name
+                .chars()
+                .all(|c| char::is_alphanumeric(c) || c == '-' || c == '.')
+        {
+            return Err(Error::InvalidName(name.to_string()));
+        }
+
+        if bytes64.len() != BASE64.encode_len(PUBLIC_KEY_LENGTH) {
+            return Err(Error::InvalidPubKeyLen(bytes64.len()));
+        }
+
+        let mut buf = [0; PUBLIC_KEY_LENGTH + 1];
+        let mut bytes = [0; PUBLIC_KEY_LENGTH];
+        match BASE64.decode_mut(bytes64.as_bytes(), &mut buf) {
+            Ok(PUBLIC_KEY_LENGTH) => {
+                bytes.copy_from_slice(&buf[..PUBLIC_KEY_LENGTH]);
+            }
+            Ok(_) => unreachable!(),
+            // keeping DecodePartial gets annoying lifetime-wise
+            Err(_) => return Err(Error::DecodeError(input.to_string())),
+        }
+
+        let verifying_key = VerifyingKey::from_bytes(&bytes).map_err(Error::InvalidVerifyingKey)?;
+
+        Ok(Self {
+            name: name.to_string(),
+            verifying_key,
+        })
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    /// Verify the passed in signature is a correct signature for the passed in fingerprint and is signed
+    /// by the key material referred to by [Self],
+    /// which means the name in the signature has to match,
+    /// and the signature bytes themselves need to be a valid signature made by
+    /// the signing key identified by [Self::verifying key].
+    pub fn verify(&self, fingerprint: &str, signature: &Signature) -> bool {
+        if self.name() != signature.name() {
+            return false;
+        }
+
+        return signature.verify(fingerprint.as_bytes(), &self.verifying_key);
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("Invalid name: {0}")]
+    InvalidName(String),
+    #[error("Missing separator")]
+    MissingSeparator,
+    #[error("Invalid pubkey len: {0}")]
+    InvalidPubKeyLen(usize),
+    #[error("VerifyingKey error: {0}")]
+    InvalidVerifyingKey(ed25519_dalek::SignatureError),
+    #[error("Unable to base64-decode pubkey: {0}")]
+    DecodeError(String),
+}
+
+impl Display for PubKey {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}:{}",
+            self.name,
+            BASE64.encode(self.verifying_key.as_bytes())
+        )
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use data_encoding::BASE64;
+    use ed25519_dalek::PUBLIC_KEY_LENGTH;
+    use rstest::rstest;
+
+    use crate::narinfo::Signature;
+
+    use super::PubKey;
+    const FINGERPRINT: &str = "1;/nix/store/syd87l2rxw8cbsxmxl853h0r6pdwhwjr-curl-7.82.0-bin;sha256:1b4sb93wp679q4zx9k1ignby1yna3z7c4c2ri3wphylbc2dwsys0;196040;/nix/store/0jqd0rlxzra1rs38rdxl43yh6rxchgc6-curl-7.82.0,/nix/store/6w8g7njm4mck5dmjxws0z1xnrxvl81xa-glibc-2.34-115,/nix/store/j5jxw3iy7bbz4a57fh9g2xm2gxmyal8h-zlib-1.2.12,/nix/store/yxvjs9drzsphm9pcf42a4byzj1kb9m7k-openssl-1.1.1n";
+
+    #[rstest]
+    #[case::cache_nixos_org("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", "cache.nixos.org-1", &BASE64.decode(b"6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=").unwrap()[..].try_into().unwrap())]
+    #[case::cache_nixos_org_different_name("cheesecake:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", "cheesecake", &BASE64.decode(b"6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=").unwrap()[..].try_into().unwrap())]
+    #[case::test_1("test1:tLAEn+EeaBUJYqEpTd2yeerr7Ic6+0vWe+aXL/vYUpE=", "test1", &BASE64.decode(b"tLAEn+EeaBUJYqEpTd2yeerr7Ic6+0vWe+aXL/vYUpE=").unwrap()[..].try_into().unwrap())]
+    fn parse(
+        #[case] input: &'static str,
+        #[case] exp_name: &'static str,
+        #[case] exp_verifying_key_bytes: &[u8; PUBLIC_KEY_LENGTH],
+    ) {
+        let pubkey = PubKey::parse(input).expect("must parse");
+        assert_eq!(exp_name, pubkey.name());
+        assert_eq!(exp_verifying_key_bytes, pubkey.verifying_key.as_bytes());
+    }
+
+    #[rstest]
+    #[case::empty_name("6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=")]
+    #[case::missing_padding("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY")]
+    #[case::wrong_length("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDS")]
+    fn parse_fail(#[case] input: &'static str) {
+        PubKey::parse(input).expect_err("must fail");
+    }
+
+    #[rstest]
+    #[case::correct_cache_nixos_org("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", FINGERPRINT, "cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", true)]
+    #[case::wrong_name_mismatch("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", FINGERPRINT, "cache.nixos.org:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", false)]
+    fn verify(
+        #[case] pubkey_str: &'static str,
+        #[case] fingerprint: &'static str,
+        #[case] signature_str: &'static str,
+        #[case] expected: bool,
+    ) {
+        let pubkey = PubKey::parse(pubkey_str).expect("must parse");
+        let signature = Signature::parse(signature_str).expect("must parse");
+
+        assert_eq!(expected, pubkey.verify(fingerprint, &signature));
+    }
+}
diff --git a/tvix/nix-compat/src/narinfo/signature.rs b/tvix/nix-compat/src/narinfo/signature.rs
new file mode 100644
index 0000000000..fd197e771d
--- /dev/null
+++ b/tvix/nix-compat/src/narinfo/signature.rs
@@ -0,0 +1,184 @@
+use std::fmt::{self, Display};
+
+use data_encoding::BASE64;
+use ed25519_dalek::SIGNATURE_LENGTH;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Signature<'a> {
+    name: &'a str,
+    bytes: [u8; SIGNATURE_LENGTH],
+}
+
+impl<'a> Signature<'a> {
+    pub fn new(name: &'a str, bytes: [u8; SIGNATURE_LENGTH]) -> Self {
+        Self { name, bytes }
+    }
+
+    pub fn parse(input: &'a str) -> Result<Self, Error> {
+        let (name, bytes64) = input.split_once(':').ok_or(Error::MissingSeparator)?;
+
+        if name.is_empty()
+            || !name
+                .chars()
+                .all(|c| char::is_alphanumeric(c) || c == '-' || c == '.')
+        {
+            return Err(Error::InvalidName(name.to_string()));
+        }
+
+        if bytes64.len() != BASE64.encode_len(SIGNATURE_LENGTH) {
+            return Err(Error::InvalidSignatureLen(bytes64.len()));
+        }
+
+        let mut bytes = [0; SIGNATURE_LENGTH];
+        let mut buf = [0; SIGNATURE_LENGTH + 2];
+        match BASE64.decode_mut(bytes64.as_bytes(), &mut buf) {
+            Ok(SIGNATURE_LENGTH) => bytes.copy_from_slice(&buf[..SIGNATURE_LENGTH]),
+            Ok(_) => unreachable!(),
+            // keeping DecodePartial gets annoying lifetime-wise
+            Err(_) => return Err(Error::DecodeError(input.to_string())),
+        }
+
+        Ok(Signature { name, bytes })
+    }
+
+    pub fn name(&self) -> &'a str {
+        self.name
+    }
+
+    pub fn bytes(&self) -> &[u8; SIGNATURE_LENGTH] {
+        &self.bytes
+    }
+
+    /// For a given fingerprint and ed25519 verifying key, ensure if the signature is valid.
+    pub fn verify(&self, fingerprint: &[u8], verifying_key: &ed25519_dalek::VerifyingKey) -> bool {
+        let signature = ed25519_dalek::Signature::from_bytes(self.bytes());
+
+        verifying_key.verify_strict(fingerprint, &signature).is_ok()
+    }
+}
+
+impl<'de: 'a, 'a> Deserialize<'de> for Signature<'a> {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let str: &'de str = Deserialize::deserialize(deserializer)?;
+        Self::parse(str).map_err(|_| {
+            serde::de::Error::invalid_value(serde::de::Unexpected::Str(str), &"Signature")
+        })
+    }
+}
+
+impl<'a> Serialize for Signature<'a> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let string: String = self.to_string();
+
+        string.serialize(serializer)
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("Invalid name: {0}")]
+    InvalidName(String),
+    #[error("Missing separator")]
+    MissingSeparator,
+    #[error("Invalid signature len: (expected {} b64-encoded, got {}", BASE64.encode_len(SIGNATURE_LENGTH), .0)]
+    InvalidSignatureLen(usize),
+    #[error("Unable to base64-decode signature: {0}")]
+    DecodeError(String),
+}
+
+impl Display for Signature<'_> {
+    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
+        write!(w, "{}:{}", self.name, BASE64.encode(&self.bytes))
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use data_encoding::BASE64;
+    use ed25519_dalek::VerifyingKey;
+    use hex_literal::hex;
+    use lazy_static::lazy_static;
+
+    use super::Signature;
+    use rstest::rstest;
+
+    const FINGERPRINT: &str = "1;/nix/store/syd87l2rxw8cbsxmxl853h0r6pdwhwjr-curl-7.82.0-bin;sha256:1b4sb93wp679q4zx9k1ignby1yna3z7c4c2ri3wphylbc2dwsys0;196040;/nix/store/0jqd0rlxzra1rs38rdxl43yh6rxchgc6-curl-7.82.0,/nix/store/6w8g7njm4mck5dmjxws0z1xnrxvl81xa-glibc-2.34-115,/nix/store/j5jxw3iy7bbz4a57fh9g2xm2gxmyal8h-zlib-1.2.12,/nix/store/yxvjs9drzsphm9pcf42a4byzj1kb9m7k-openssl-1.1.1n";
+
+    // The signing key labelled as `cache.nixos.org-1`,
+    lazy_static! {
+        static ref PUB_CACHE_NIXOS_ORG_1: VerifyingKey = ed25519_dalek::VerifyingKey::from_bytes(
+            BASE64
+                .decode(b"6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=")
+                .unwrap()[..]
+                .try_into()
+                .unwrap()
+        )
+        .unwrap();
+        static ref PUB_TEST_1: VerifyingKey = ed25519_dalek::VerifyingKey::from_bytes(
+            BASE64
+                .decode(b"tLAEn+EeaBUJYqEpTd2yeerr7Ic6+0vWe+aXL/vYUpE=")
+                .unwrap()[..]
+                .try_into()
+                .unwrap()
+        )
+        .unwrap();
+    }
+
+    #[rstest]
+    #[case::valid_cache_nixos_org_1(&PUB_CACHE_NIXOS_ORG_1, &"cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", FINGERPRINT, true)]
+    #[case::valid_test1(&PUB_CACHE_NIXOS_ORG_1, &"cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", FINGERPRINT, true)]
+    #[case::valid_cache_nixos_org_different_name(&PUB_CACHE_NIXOS_ORG_1, &"cache.nixos.org-2:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", FINGERPRINT, true)]
+    #[case::fail_invalid_cache_nixos_org_1_signature(&PUB_CACHE_NIXOS_ORG_1, &"cache.nixos.org-1:TsTTb000000000000000000000000ytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", FINGERPRINT, false)]
+    #[case::fail_valid_sig_but_wrong_fp_cache_nixos_org_1(&PUB_CACHE_NIXOS_ORG_1, &"cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==", &FINGERPRINT[0..5], false)]
+    fn verify_sigs(
+        #[case] verifying_key: &VerifyingKey,
+        #[case] sig_str: &'static str,
+        #[case] fp: &str,
+        #[case] expect_valid: bool,
+    ) {
+        let sig = Signature::parse(sig_str).expect("must parse");
+        assert_eq!(expect_valid, sig.verify(fp.as_bytes(), verifying_key));
+    }
+
+    #[rstest]
+    #[case::wrong_length("cache.nixos.org-1:o1DTsjCz0PofLJ216P2RBuSulI8BAb6zHxWE4N+tzlcELk5Uk/GO2SCxWTRN5wJutLZZ+cHTMdWqOHF8")]
+    #[case::wrong_name_newline("test\n:u01BybwQhyI5H1bW1EIWXssMDhDDIvXOG5uh8Qzgdyjz6U1qg6DHhMAvXZOUStIj6X5t4/ufFgR8i3fjf0bMAw==")]
+    #[case::wrong_name_space("test :u01BybwQhyI5H1bW1EIWXssMDhDDIvXOG5uh8Qzgdyjz6U1qg6DHhMAvXZOUStIj6X5t4/ufFgR8i3fjf0bMAw==")]
+    #[case::empty_name(
+        ":u01BybwQhyI5H1bW1EIWXssMDhDDIvXOG5uh8Qzgdyjz6U1qg6DHhMAvXZOUStIj6X5t4/ufFgR8i3fjf0bMAw=="
+    )]
+    #[case::b64_only(
+        "u01BybwQhyI5H1bW1EIWXssMDhDDIvXOG5uh8Qzgdyjz6U1qg6DHhMAvXZOUStIj6X5t4/ufFgR8i3fjf0bMAw=="
+    )]
+    fn parse_fail(#[case] input: &'static str) {
+        Signature::parse(input).expect_err("must fail");
+    }
+
+    #[test]
+    fn serialize_deserialize() {
+        let signature_actual = Signature {
+            name: "cache.nixos.org-1",
+            bytes: hex!(
+                r#"4e c4 d3 6f 75 86 4d 92  a9 86 f6 1d 04 75 f0 a3
+                   ac 1e 54 82 e6 4f 2b 54  8c b0 7e bd c5 fc f5 f3
+                   a3 8d 18 9c 08 79 8a 03  84 42 3c c5 4b 92 3e 93
+                   30 9e 06 31 7d c7 3d 55  91 74 3d 61 91 e2 99 05"#
+            ),
+        };
+        let signature_str_json = "\"cache.nixos.org-1:TsTTb3WGTZKphvYdBHXwo6weVILmTytUjLB+vcX89fOjjRicCHmKA4RCPMVLkj6TMJ4GMX3HPVWRdD1hkeKZBQ==\"";
+
+        let serialized = serde_json::to_string(&signature_actual).expect("must serialize");
+        assert_eq!(signature_str_json, &serialized);
+
+        let deserialized: Signature<'_> =
+            serde_json::from_str(signature_str_json).expect("must deserialize");
+        assert_eq!(&signature_actual, &deserialized);
+    }
+}