diff --git a/src/action/base/fetch_and_unpack_nix_substituter.rs b/src/action/base/fetch_and_unpack_nix_substituter.rs index 6127f26..9700cb1 100644 --- a/src/action/base/fetch_and_unpack_nix_substituter.rs +++ b/src/action/base/fetch_and_unpack_nix_substituter.rs @@ -1,10 +1,11 @@ use std::{ collections::HashMap, + io::BufRead, path::{Path, PathBuf}, }; use base64::Engine; -use ed25519_dalek::VerifyingKey; +use ed25519_dalek::{SignatureError, VerifyingKey}; use reqwest::Url; use tracing::{span, Span}; @@ -74,7 +75,7 @@ impl FetchAndUnpackNixSubstituter { } Ok(Self { - target: StorePath::from_full_path(target) + target: StorePath::from_path(&target) .ok_or_else(|| Self::error(SubstitutionError::InvalidStorePath))?, trusted_keys: trusted_keys_parsed, dest, @@ -95,7 +96,7 @@ impl Action for FetchAndUnpackNixSubstituter { fn tracing_synopsis(&self) -> String { format!( "Fetch `{}` from substituters to `{}`", - self.target.full_path.to_string_lossy(), + self.target.full_path, self.dest.display() ) } @@ -182,26 +183,47 @@ impl StorePath { return None; } - let remaining = + let (_, full_name) = full_path.split_at(STORE_DIR.len()); - let full_name = full_path.file_name()?.to_str()?.to_owned(); + let (digest, name) = full_name.split_once('-')?; + + if digest.len() != 32 + || digest.contains(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit()) + { + return None; + } + + if name.contains(|c: char| { + !c.is_ascii_alphanumeric() + && c != '+' + && c != '-' + && c != '.' + && c != '_' + && c != '?' + && c != '=' + }) { + return None; + } - let (digest, _) = full_name.split_once('-')?; Some(Self { - full_path, - digest: digest.to_owned(), - full_name, + full_path: full_path.to_string(), + digest: digest.to_string(), + full_name: full_name.to_string(), }) } - pub fn from_full_name(full_name: String) -> Option { - let (digest, _) = full_name.split_once('-')?; + pub fn from_path(path: &Path) -> Option { + Self::from_full_path(path.to_str()?) + } - Some(Self { - full_path: Path::new(STORE_DIR).join(&full_name), - digest: digest.to_string(), - full_name, - }) + pub fn from_full_name(full_name: &str) -> Option { + Self::from_full_path( + &Path::new(STORE_DIR) + .join(&full_name) + .into_os_string() + .into_string() + .ok()?, + ) } } @@ -241,8 +263,7 @@ impl serde::de::Visitor<'_> for StorePathVisitor { where E: serde::de::Error, { - StorePath::from_full_path(Path::new(value).to_owned()) - .ok_or_else(|| E::custom("invalid store path")) + StorePath::from_full_path(value).ok_or_else(|| E::custom("invalid store path")) } } @@ -281,19 +302,73 @@ struct NarInfo { pub nar_hash: String, /// Size of the decompressed nar in bytes pub nar_size: u64, - /// Other store paths referenced by + /// Other store paths referenced by the nar pub references: Vec, + /// Signature of the nar, used to sign other items + pub sig: (String, Vec), } impl NarInfo { fn parse(substituter_url: &Url, contents: &bytes::Bytes) -> Result { + let mut store_path = None; + let mut url = None; + let mut compression = None; + let mut nar_hash = None; + let mut nar_size = None; + let mut references = None; + let mut sig = None; + + for maybe_line in contents.lines() { + let line = maybe_line.map_err(SubstitutionError::UndecodableNarInfo)?; + let (tag, rest) = line + .split_once(':') + .ok_or_else(|| SubstitutionError::BadNarInfo)?; + let value = rest.trim_start_matches(' '); + + match tag { + "StorePath" => store_path = StorePath::from_full_path(value), + "URL" => url = substituter_url.join(value).ok(), + "Compression" => compression = NarCompression::from_name(value), + "NarHash" => { + nar_hash = { + let (algorithm, digest) = value + .split_once(':') + .ok_or_else(|| SubstitutionError::BadNarInfo)?; + if algorithm == "sha256" { + Some(digest.to_string()) + } else { + None + } + } + }, + "NarSize" => nar_size = u64::from_str_radix(value, 10).ok(), + "References" => { + references = value + .split(' ') + .map(StorePath::from_full_name) + .collect::>>() + }, + "Sig" => { + let (signer, base64_signature) = value + .split_once(':') + .ok_or_else(|| SubstitutionError::BadNarInfo)?; + let signature = base64::engine::general_purpose::STANDARD + .decode(base64_signature) + .map_err(|_| SubstitutionError::BadNarInfo)?; + + sig = Some((signer.to_string(), signature)) + }, + _ => {}, + } + } Ok(Self { - store_path: todo!(), - url: todo!(), - compression: todo!(), - nar_hash: todo!(), - nar_size: todo!(), - references: todo!(), + store_path: store_path.ok_or_else(|| SubstitutionError::BadNarInfo)?, + url: url.ok_or_else(|| SubstitutionError::BadNarInfo)?, + compression: compression.ok_or_else(|| SubstitutionError::BadNarInfo)?, + nar_hash: nar_hash.ok_or_else(|| SubstitutionError::BadNarInfo)?, + nar_size: nar_size.ok_or_else(|| SubstitutionError::BadNarInfo)?, + references: references.ok_or_else(|| SubstitutionError::BadNarInfo)?, + sig: sig.ok_or_else(|| SubstitutionError::BadNarInfo)?, }) } @@ -301,21 +376,35 @@ impl NarInfo { &self, trusted_keys: &HashMap, ) -> Result<(), SubstitutionError> { + let Some(key) = trusted_keys.get(&self.sig.0) else { + return Err(SubstitutionError::BadSignature); + }; + // Fingerprint format not documented, but implemented in lix: // https://git.lix.systems/lix-project/lix/src/commit/d461cc1d7b2f489c3886f147166ba5b5e0e37541/src/libstore/path-info.cc#L25 let fingerprint = format!( "1;{};{};{};{}", - self.store_path.full_path.to_string_lossy(), + self.store_path.full_path, self.nar_hash, self.nar_size, self.references .iter() - .map(|reference| reference.full_path) - .collect::>() + .map(|reference| reference.full_path.as_ref()) + .collect::>() .join(",") ); - todo!() + key.verify_strict( + fingerprint.as_bytes(), + &ed25519_dalek::Signature::from_bytes( + self.sig + .1 + .as_slice() + .try_into() + .map_err(|_| SubstitutionError::BadSignature)?, + ), + ) + .map_err(|_| SubstitutionError::BadSignature) } pub fn parse_and_verify( @@ -327,7 +416,7 @@ impl NarInfo { let parsed = Self::parse(substituter_url, contents)?; if &parsed.store_path != expected_store_path { - return Err(SubstitutionError::BadNarInfo); + return Err(SubstitutionError::BadSignature); } parsed.verify(trusted_keys)?; @@ -347,8 +436,12 @@ pub enum SubstitutionError { /// Normally an ed25519_dalek::SignatureError, /// but that comes with no extra information so no need to include it PublicKey, - #[error("Bad narinfo signature")] + #[error("Undecodable narinfo")] + UndecodableNarInfo(#[source] std::io::Error), + #[error("Bad narinfo contents")] BadNarInfo, + #[error("Bad narinfo signature")] + BadSignature, #[error("Invalid nix store path")] InvalidStorePath, }