Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

provenance/src/apk.rs

//! **APK provenance** (Wolfi, Chainguard, Alpine), verified offline.
//!
//! Chainguard's and Wolfi's `.apk` packages carry no signature of their own: the
//! distributor signs the repository index, `APKINDEX.tar.gz`, and the index names
//! every package's control hash. So a package verifies when, in this order:
//!
//! 1. **The index signature.** The index is two concatenated gzip streams. The
//!    first is a tar holding one `.SIGN.RSA256.<key>` (RSA PKCS#1 v1.5 over
//!    SHA-256) or `.SIGN.RSA.<key>` (over SHA-1) member; it signs the RAW
//!    (compressed) bytes of everything after it. `<key>` must be a pinned key
//!    (they cross the airgap in the trust bundle, [`crate::trust`]), and the
//!    signature is checked before a byte of the index is decompressed.
//! 2. **The control hash.** The package is gzip streams too: an optional
//!    signature segment, the control segment (`.PKGINFO`), the data. The index
//!    entry for the package's `pkgname` and `pkgver` names `C:Q1<base64 SHA-1>`
//!    (or `Q2`, SHA-256) of the control segment's raw bytes.
//! 3. **The data hash.** `.PKGINFO`'s `datahash` is the SHA-256 of the raw data
//!    segment, and the index's `S:` is the package's size.
//!
//! The data segment is hashed, never unpacked. Every decompression is capped.

use std::io::Read;

use sha2::{Digest, Sha256};

use crate::crypto::{self, Hash, PublicKey};
use crate::error::{ProvenanceError, Result};

/// The most bytes one control or signature segment may expand to.
const MAX_SMALL: u64 = 16 << 20;
/// The most bytes an index may expand to (Wolfi's is about 45 MB).
const MAX_INDEX: u64 = 512 << 20;

/// One pinned APK signing key: its file name as `.SIGN.*.<name>` writes it
/// (`wolfi-signing.rsa.pub`) and the RSA public key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApkKey {
    pub name: String,
    pub key: PublicKey,
}

impl ApkKey {
    /// Read a PEM `PUBLIC KEY` (as Wolfi and Alpine publish them).
    pub fn from_pem(name: &str, pem: &[u8]) -> Result<Self> {
        let text = std::str::from_utf8(pem)
            .map_err(|e| ProvenanceError::TrustRoot(format!("apk key {name:?}: not UTF-8: {e}")))?;
        let key = PublicKey::from_text(text)
            .map_err(|e| ProvenanceError::TrustRoot(format!("apk key {name:?}: {e}")))?;
        if !matches!(key, PublicKey::Rsa(_)) {
            return Err(ProvenanceError::TrustRoot(format!(
                "apk key {name:?} is {}, not RSA",
                key.alg()
            )));
        }
        Ok(Self {
            name: name.to_string(),
            key,
        })
    }
}

fn bad(w: impl Into<String>) -> ProvenanceError {
    ProvenanceError::Malformed(w.into())
}

/// Decompress the ONE gzip stream at the start of `raw`, capped; returns the
/// expanded bytes and how many raw bytes the stream took.
fn one_stream(raw: &[u8], cap: u64, what: &str) -> Result<(Vec<u8>, usize)> {
    let mut dec = flate2::bufread::GzDecoder::new(raw);
    let mut out = Vec::new();
    (&mut dec)
        .take(cap + 1)
        .read_to_end(&mut out)
        .map_err(|e| bad(format!("{what}: not a gzip stream: {e}")))?;
    if out.len() as u64 > cap {
        return Err(bad(format!("{what}: expands past {cap} bytes")));
    }
    let rest = dec.into_inner();
    Ok((out, raw.len() - rest.len()))
}

/// The regular files of an apk segment (a tar without its end blocks).
fn tar_files(tar: &[u8], what: &str) -> Result<Vec<(String, Vec<u8>)>> {
    let mut padded = tar.to_vec();
    padded.extend_from_slice(&[0u8; 1024]);
    let mut ar = tar::Archive::new(padded.as_slice());
    let mut out = Vec::new();
    for e in ar
        .entries()
        .map_err(|e| bad(format!("{what}: not a tar: {e}")))?
    {
        let mut e = e.map_err(|e| bad(format!("{what}: {e}")))?;
        if !e.header().entry_type().is_file() {
            continue;
        }
        let name = String::from_utf8_lossy(&e.path_bytes()).into_owned();
        let mut b = Vec::new();
        e.read_to_end(&mut b)
            .map_err(|e| bad(format!("{what}: {name}: {e}")))?;
        out.push((name, b));
    }
    Ok(out)
}

/// A `.SIGN.<alg>.<key>` member: the hash and the key name.
fn sign_member(name: &str) -> Option<(Hash, &str)> {
    if let Some(k) = name.strip_prefix(".SIGN.RSA256.") {
        return Some((Hash::Sha256, k));
    }
    name.strip_prefix(".SIGN.RSA.").map(|k| (Hash::Sha1, k))
}

/// Does this material look like an APK index (by name, or by its first segment)?
pub fn is_index(name: &str, bytes: &[u8]) -> bool {
    if name.ends_with("APKINDEX.tar.gz") {
        return true;
    }
    bytes.starts_with(&[0x1f, 0x8b])
        && one_stream(bytes, MAX_SMALL, "index")
            .ok()
            .and_then(|(t, _)| tar_files(&t, "index").ok())
            .is_some_and(|f| f.len() == 1 && sign_member(&f[0].0).is_some())
}

/// One package as the index names it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
    pub name: String,
    pub version: String,
    pub arch: String,
    /// `Q1…` (SHA-1) or `Q2…` (SHA-256), base64.
    pub checksum: String,
    pub size: Option<u64>,
    /// `D:`, as written (`so:libc.so.6`, `glibc>=2.38`, `!conflict`).
    pub depends: Vec<String>,
    /// `p:`, as written (`so:libffi.so.8=8`, `cmd:jq=1.7.1-r3`).
    pub provides: Vec<String>,
    /// The stanza's lines as the index wrote them, without the blank line after it:
    /// what an index that lists this package again writes.
    pub stanza: String,
}

/// An index whose signature verified.
#[derive(Debug, Clone)]
pub struct VerifiedIndex {
    pub key: String,
    /// `rsa-sha256` or `rsa-sha1`.
    pub alg: &'static str,
    pub entries: Vec<IndexEntry>,
}

/// ★ Verify an `APKINDEX.tar.gz` against the pinned keys, then read it.
pub fn verify_index(bytes: &[u8], keys: &[ApkKey]) -> Result<VerifiedIndex> {
    let (sig_tar, used) = one_stream(bytes, MAX_SMALL, "the index's signature segment")?;
    let files = tar_files(&sig_tar, "the index's signature segment")?;
    let (hash, key_name, sig) = files
        .iter()
        .find_map(|(n, b)| sign_member(n).map(|(h, k)| (h, k.to_string(), b.clone())))
        .ok_or_else(|| {
            ProvenanceError::Unsigned(
                "the APK index carries no .SIGN.RSA256 or .SIGN.RSA member".into(),
            )
        })?;
    let signed = &bytes[used..];
    if signed.is_empty() {
        return Err(bad("the APK index has a signature and no index after it"));
    }
    let key = keys.iter().find(|k| k.name == key_name).ok_or_else(|| {
        let pinned: Vec<&str> = keys.iter().map(|k| k.name.as_str()).collect();
        ProvenanceError::SignerUntrusted(format!("the APK index is signed by {key_name:?}, which is not a pinned apk key (pinned: {pinned:?})"))
    })?;
    if !key.key.verify_with(hash, signed, &sig) {
        return Err(ProvenanceError::SignatureInvalid(format!(
            "the APK index signature does not verify with the pinned key {key_name:?}"
        )));
    }
    let alg = if hash == Hash::Sha256 {
        "rsa-sha256"
    } else {
        "rsa-sha1"
    };
    let (idx_tar, _) = one_stream(signed, MAX_INDEX, "the APK index")?;
    let idx = tar_files(&idx_tar, "the APK index")?;
    let text = idx
        .iter()
        .find(|(n, _)| n == "APKINDEX")
        .map(|(_, b)| String::from_utf8_lossy(b).into_owned())
        .ok_or_else(|| bad("the signed APK index holds no APKINDEX file"))?;
    let mut entries = Vec::new();
    for stanza in text.split("\n\n") {
        let stanza = stanza.trim_matches('\n');
        let (mut name, mut version, mut arch, mut checksum, mut size) =
            (None, None, String::new(), None, None);
        let (mut depends, mut provides) = (Vec::new(), Vec::new());
        let words = |v: &str| v.split_whitespace().map(str::to_string).collect::<Vec<_>>();
        for l in stanza.lines() {
            match l.split_once(':') {
                Some(("P", v)) => name = Some(v.to_string()),
                Some(("V", v)) => version = Some(v.to_string()),
                Some(("A", v)) => arch = v.to_string(),
                Some(("C", v)) => checksum = Some(v.to_string()),
                Some(("S", v)) => size = v.trim().parse().ok(),
                Some(("D", v)) => depends = words(v),
                Some(("p", v)) => provides = words(v),
                _ => {}
            }
        }
        if let (Some(name), Some(version), Some(checksum)) = (name, version, checksum) {
            entries.push(IndexEntry {
                name,
                version,
                arch,
                checksum,
                size,
                depends,
                provides,
                stanza: stanza.to_string(),
            });
        }
    }
    Ok(VerifiedIndex {
        key: key_name,
        alg,
        entries,
    })
}

/// What a verified package is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedPackage {
    pub name: String,
    pub version: String,
    pub arch: String,
    pub datahash: String,
}

fn pkginfo(text: &str, key: &str) -> Option<String> {
    text.lines().find_map(|l| {
        l.split_once(" = ")
            .filter(|(k, _)| k.trim() == key)
            .map(|(_, v)| v.trim().to_string())
    })
}

/// ★ Verify a package against a verified index: the control hash the index names,
/// the data hash `.PKGINFO` names, the size.
pub fn verify_package(apk: &[u8], index: &VerifiedIndex) -> Result<VerifiedPackage> {
    let (first, used) = one_stream(apk, MAX_SMALL, "the package's first segment")?;
    let files = tar_files(&first, "the package's first segment")?;
    let (control_raw, control_files, rest) = if files.iter().any(|(n, _)| sign_member(n).is_some())
    {
        let after = &apk[used..];
        let (ctl, n) = one_stream(after, MAX_SMALL, "the package's control segment")?;
        (
            &after[..n],
            tar_files(&ctl, "the package's control segment")?,
            &after[n..],
        )
    } else {
        (&apk[..used], files, &apk[used..])
    };
    let info = control_files
        .iter()
        .find(|(n, _)| n == ".PKGINFO")
        .map(|(_, b)| String::from_utf8_lossy(b).into_owned())
        .ok_or_else(|| bad("the package's control segment holds no .PKGINFO"))?;
    let name = pkginfo(&info, "pkgname").ok_or_else(|| bad(".PKGINFO names no pkgname"))?;
    let version = pkginfo(&info, "pkgver").ok_or_else(|| bad(".PKGINFO names no pkgver"))?;
    let arch = pkginfo(&info, "arch").unwrap_or_default();
    let entry = index
        .entries
        .iter()
        .find(|e| {
            e.name == name
                && e.version == version
                && (e.arch.is_empty() || arch.is_empty() || e.arch == arch)
        })
        .ok_or_else(|| {
            ProvenanceError::SubjectMismatch(format!(
                "the signed APK index names no {name} {version} ({arch})"
            ))
        })?;
    let got = if let Some(b64) = entry.checksum.strip_prefix("Q1") {
        (
            b64,
            crypto::b64_encode(
                ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, control_raw).as_ref(),
            ),
        )
    } else if let Some(b64) = entry.checksum.strip_prefix("Q2") {
        (b64, crypto::b64_encode(&crypto::sha256(control_raw)))
    } else {
        return Err(bad(format!(
            "the index's checksum {:?} for {name} {version} is neither Q1 nor Q2",
            entry.checksum
        )));
    };
    if got.0 != got.1 {
        return Err(ProvenanceError::SubjectMismatch(format!(
            "{name} {version}: the control segment's hash is not the one the signed index names"
        )));
    }
    if let Some(s) = entry.size {
        if s != apk.len() as u64 {
            return Err(ProvenanceError::SubjectMismatch(format!(
                "{name} {version}: the package is {} bytes, the signed index says {s}",
                apk.len()
            )));
        }
    }
    let datahash = pkginfo(&info, "datahash")
        .ok_or_else(|| bad(format!("{name} {version}: .PKGINFO names no datahash")))?;
    let actual = hex::encode(Sha256::digest(rest));
    if !datahash.eq_ignore_ascii_case(&actual) {
        return Err(ProvenanceError::SubjectMismatch(format!(
            "{name} {version}: the data segment's sha256 is {actual}, .PKGINFO names {datahash}"
        )));
    }
    Ok(VerifiedPackage {
        name,
        version,
        arch,
        datahash: actual,
    })
}