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,
})
}